@centrali-io/centrali-sdk 5.5.1 → 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
@@ -0,0 +1,186 @@
1
+ // =====================================================
2
+ // Records Manager (canonical query surface — CEN-1194)
3
+ // =====================================================
4
+
5
+ import type { Method } from 'axios';
6
+ import type { ApiResponse } from '../types/common';
7
+ import type { QueryRecordOptions } from '../types/records';
8
+ import type {
9
+ QueryDefinition,
10
+ QueryResult,
11
+ WhereExpression,
12
+ SortClause,
13
+ PageClause,
14
+ SelectClause,
15
+ } from '../../query-types';
16
+ import { getRecordApiPath } from '../internal/paths';
17
+ import { validateQueryDefinition } from '@centrali/query';
18
+ import { queryErrorsToCentraliError } from './query';
19
+
20
+
21
+ /**
22
+ * RecordsManager exposes the canonical query surface for records.
23
+ *
24
+ * All three methods compile to a single canonical `QueryDefinition` server-side
25
+ * and return the canonical `{ data, meta }` envelope (`QueryResult<T>`).
26
+ *
27
+ * - {@link RecordsManager.query | query} — full POST `/records/query`. Use for
28
+ * nested boolean trees, `select`, `text`, `include`.
29
+ * - {@link RecordsManager.list | list} — GET adapter for simple URL-param
30
+ * queries. Bookmarkable, cacheable, but cannot express nested `or`/`not`.
31
+ * - {@link RecordsManager.search | search} — sugar over `query({ text: ... })`.
32
+ *
33
+ * Access via `client.records`.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * // Canonical query — boolean tree, select, paging
38
+ * const open = await client.records.query<Order>('orders', {
39
+ * resource: 'orders',
40
+ * where: {
41
+ * and: [
42
+ * { 'data.status': { eq: 'open' } },
43
+ * { or: [
44
+ * { 'data.amount': { gte: 100 } },
45
+ * { 'data.priority': { eq: 'high' } }
46
+ * ]}
47
+ * ]
48
+ * },
49
+ * sort: [{ field: 'createdAt', direction: 'desc' }],
50
+ * page: { limit: 50 },
51
+ * select: { fields: ['id', 'data.status', 'data.amount'] }
52
+ * });
53
+ * console.log(open.data, open.meta.hasMore);
54
+ *
55
+ * // GET adapter — simple cases
56
+ * const recent = await client.records.list<Order>('orders', {
57
+ * 'data.status': 'paid',
58
+ * sort: '-createdAt',
59
+ * pageSize: 25,
60
+ * });
61
+ *
62
+ * // Text search — sugar over { text }
63
+ * const matches = await client.records.search<Order>('orders', 'urgent shipping');
64
+ * ```
65
+ */
66
+ export class RecordsManager {
67
+ private requestFn: <T>(method: Method, path: string, data?: any, queryParams?: Record<string, any>) => Promise<ApiResponse<T>>;
68
+ private workspaceId: string;
69
+
70
+ constructor(
71
+ workspaceId: string,
72
+ requestFn: <T>(method: Method, path: string, data?: any, queryParams?: Record<string, any>) => Promise<ApiResponse<T>>
73
+ ) {
74
+ this.workspaceId = workspaceId;
75
+ this.requestFn = requestFn;
76
+ }
77
+
78
+ /**
79
+ * Run a canonical query against `POST /records/query`.
80
+ *
81
+ * The body **is** a `QueryDefinition`. Returns the canonical `{ data, meta }`
82
+ * envelope. If `definition.resource` is omitted, `resource` is filled in
83
+ * from the first argument so the wire shape always matches the contract.
84
+ *
85
+ * @example
86
+ * const open = await client.records.query<Order>('orders', {
87
+ * resource: 'orders',
88
+ * where: { 'data.status': { eq: 'open' } },
89
+ * page: { limit: 100 }
90
+ * });
91
+ */
92
+ public async query<T = any>(
93
+ resource: string,
94
+ definition: Omit<QueryDefinition, 'resource'> & { resource?: string }
95
+ ): Promise<QueryResult<T>> {
96
+ const path = `data/workspace/${this.workspaceId}/api/v1/records/query`;
97
+ const body: QueryDefinition = { ...definition, resource: definition.resource ?? resource };
98
+ // Local pre-validation (CEN-1202). The SDK bundles the same
99
+ // validator the data service runs server-side, so a malformed
100
+ // body is rejected before the HTTP roundtrip with the same
101
+ // `CentraliError` shape the server would have returned.
102
+ const validation = validateQueryDefinition(body);
103
+ if (!validation.ok) {
104
+ throw queryErrorsToCentraliError(validation.errors);
105
+ }
106
+ const resp = await this.requestFn<T[]>('POST', path, body);
107
+ // POST /records/query returns canonical `{ data, meta }`. The SDK's
108
+ // `request()` normalizer leaves objects with a `data` key untouched, so
109
+ // the runtime shape already matches `QueryResult<T>` — we only need to
110
+ // re-type the response. `meta` is server-guaranteed for this route.
111
+ return resp as unknown as QueryResult<T>;
112
+ }
113
+
114
+ /**
115
+ * Authoring-time dry-run of a canonical query against `POST /records/query/test`.
116
+ *
117
+ * Same input shape as {@link RecordsManager.query | query}. Returns
118
+ * `422 unreadable_field` on fields the caller can't see (instead of the
119
+ * runtime privacy-preserving empty-result behavior). Use from query
120
+ * builders to surface precise errors to the author.
121
+ */
122
+ public async test<T = any>(
123
+ resource: string,
124
+ definition: Omit<QueryDefinition, 'resource'> & { resource?: string }
125
+ ): Promise<QueryResult<T>> {
126
+ const path = `data/workspace/${this.workspaceId}/api/v1/records/query/test`;
127
+ const body: QueryDefinition = { ...definition, resource: definition.resource ?? resource };
128
+ const validation = validateQueryDefinition(body);
129
+ if (!validation.ok) {
130
+ throw queryErrorsToCentraliError(validation.errors);
131
+ }
132
+ const resp = await this.requestFn<T[]>('POST', path, body);
133
+ return resp as unknown as QueryResult<T>;
134
+ }
135
+
136
+ /**
137
+ * GET adapter for simple, URL-encodable queries (`?status=paid&sort=-createdAt`).
138
+ *
139
+ * Server-side this routes through the same canonical engine as
140
+ * {@link RecordsManager.query | query} (per CEN-1181 WS3) — the URL
141
+ * params compile into a `QueryDefinition` before execution. Use this when
142
+ * a flat AND of field conditions is enough; reach for `query()` when you
143
+ * need nested booleans, `select`, `text`, or `include`.
144
+ *
145
+ * Returns the records-list `ApiResponse<T[]>` envelope unchanged so
146
+ * callers that read `meta.page` / `meta.pageSize` keep working during
147
+ * the deprecation window.
148
+ */
149
+ public list<T = any>(
150
+ resource: string,
151
+ urlOpts?: QueryRecordOptions
152
+ ): Promise<ApiResponse<T[]>> {
153
+ const path = getRecordApiPath(this.workspaceId, resource);
154
+ return this.requestFn<T[]>('GET', path, null, urlOpts);
155
+ }
156
+
157
+ /**
158
+ * Full-text search sugar — equivalent to `query(resource, { text: ... })`.
159
+ *
160
+ * Routes through `RecordsSearchExecutor` (Meilisearch) when only `text` is
161
+ * provided, or through the hybrid Meili-first path when `where` is also
162
+ * set. Result envelope is identical across pure-filter, pure-text, and
163
+ * hybrid (`{ data, meta }`).
164
+ */
165
+ public async search<T = any>(
166
+ resource: string,
167
+ text: string,
168
+ opts?: {
169
+ fields?: string[];
170
+ typoTolerance?: boolean;
171
+ where?: WhereExpression;
172
+ sort?: SortClause[];
173
+ page?: PageClause;
174
+ select?: SelectClause;
175
+ }
176
+ ): Promise<QueryResult<T>> {
177
+ return this.query<T>(resource, {
178
+ resource,
179
+ text: { query: text, fields: opts?.fields, typoTolerance: opts?.typoTolerance },
180
+ where: opts?.where,
181
+ sort: opts?.sort,
182
+ page: opts?.page,
183
+ select: opts?.select,
184
+ });
185
+ }
186
+ }
@@ -0,0 +1,374 @@
1
+ // =====================================================
2
+ // Smart Queries Manager
3
+ // =====================================================
4
+
5
+ import type { Method } from 'axios';
6
+ import type { ApiResponse } from '../types/common';
7
+ import type {
8
+ SmartQuery,
9
+ ListSmartQueryOptions,
10
+ ExecuteSmartQueryOptions,
11
+ ExecuteSavedQueryValues,
12
+ CreateSmartQueryInput,
13
+ UpdateSmartQueryInput,
14
+ TestSmartQueryInput,
15
+ } from '../types/smartQueries';
16
+ import {
17
+ getSmartQueriesApiPath,
18
+ getSmartQueriesStructureApiPath,
19
+ getSmartQueryByNameApiPath,
20
+ getSavedQueryCanonicalCollectionPath,
21
+ getSavedQueryCanonicalByIdPath,
22
+ getSavedQueryCanonicalExecutePath,
23
+ getSavedQueryCanonicalTestPath,
24
+ getSmartQueryTestApiPath,
25
+ } from '../internal/paths';
26
+
27
+ // =====================================================
28
+ // Smart Queries Manager
29
+ // =====================================================
30
+
31
+ /**
32
+ * SmartQueriesManager — reusable, parameterized saved queries. Access via
33
+ * `client.savedQueries` (canonical) or `client.smartQueries` (deprecated alias).
34
+ *
35
+ * Phase 4 of the query foundation (CEN-1198) shipped the canonical
36
+ * `/saved-queries/*` HTTP routes; this manager now routes through them. The
37
+ * data service dual-mounts the deprecated `/smart-queries/*` alias for the
38
+ * deprecation window. New code that doesn't need saved queries should still
39
+ * prefer `client.records.query()` for ad-hoc queries.
40
+ *
41
+ * Usage:
42
+ * ```ts
43
+ * // List saved queries for a structure
44
+ * const queries = await client.savedQueries.list('employee');
45
+ *
46
+ * // Execute a saved query by ID
47
+ * const results = await client.savedQueries.execute('employee', 'query-uuid');
48
+ *
49
+ * // Get a saved query by name
50
+ * const query = await client.savedQueries.getByName('employee', 'Active Employees');
51
+ * ```
52
+ */
53
+ export class SmartQueriesManager {
54
+ private requestFn: <T>(method: Method, path: string, data?: any, queryParams?: Record<string, any>) => Promise<ApiResponse<T>>;
55
+ private workspaceId: string;
56
+
57
+ constructor(
58
+ workspaceId: string,
59
+ requestFn: <T>(method: Method, path: string, data?: any, queryParams?: Record<string, any>) => Promise<ApiResponse<T>>
60
+ ) {
61
+ this.workspaceId = workspaceId;
62
+ this.requestFn = requestFn;
63
+ }
64
+
65
+ /**
66
+ * List all smart queries in the workspace.
67
+ *
68
+ * @param options - Optional list parameters (pagination, search, etc.)
69
+ * @returns List of smart queries with pagination metadata
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * // List all smart queries
74
+ * const queries = await client.smartQueries.listAll();
75
+ *
76
+ * // With pagination
77
+ * const queries = await client.smartQueries.listAll({ page: 1, limit: 20 });
78
+ * ```
79
+ */
80
+ public listAll(options?: ListSmartQueryOptions): Promise<ApiResponse<SmartQuery[]>> {
81
+ const path = getSmartQueriesApiPath(this.workspaceId);
82
+ return this.requestFn<SmartQuery[]>('GET', path, null, options);
83
+ }
84
+
85
+ /**
86
+ * List smart queries for a specific structure.
87
+ *
88
+ * @param structureSlug - The structure's record slug (e.g., "employee")
89
+ * @param options - Optional list parameters (pagination, search, etc.)
90
+ * @returns List of smart queries for the structure
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * // List queries for employee structure
95
+ * const queries = await client.smartQueries.list('employee');
96
+ *
97
+ * // With pagination and search
98
+ * const queries = await client.smartQueries.list('employee', {
99
+ * page: 1,
100
+ * limit: 10,
101
+ * search: 'active'
102
+ * });
103
+ * ```
104
+ */
105
+ public list(structureSlug: string, options?: ListSmartQueryOptions): Promise<ApiResponse<SmartQuery[]>> {
106
+ const path = getSmartQueriesStructureApiPath(this.workspaceId, structureSlug);
107
+ return this.requestFn<SmartQuery[]>('GET', path, null, options);
108
+ }
109
+
110
+ /**
111
+ * Get a smart query by ID.
112
+ *
113
+ * @param structureSlug - The structure's record slug
114
+ * @param queryId - The smart query UUID
115
+ * @returns The smart query details
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const query = await client.smartQueries.get('employee', 'query-uuid');
120
+ * console.log('Query name:', query.data.name);
121
+ * ```
122
+ */
123
+ public get(structureSlug: string, queryId: string): Promise<ApiResponse<SmartQuery>> {
124
+ const path = getSmartQueriesStructureApiPath(this.workspaceId, structureSlug, queryId);
125
+ return this.requestFn<SmartQuery>('GET', path);
126
+ }
127
+
128
+ /**
129
+ * Get a smart query by name.
130
+ *
131
+ * @param structureSlug - The structure's record slug
132
+ * @param name - The smart query name
133
+ * @returns The smart query details
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * const query = await client.smartQueries.getByName('employee', 'Active Employees');
138
+ * console.log('Query ID:', query.data.id);
139
+ * ```
140
+ */
141
+ public getByName(structureSlug: string, name: string): Promise<ApiResponse<SmartQuery>> {
142
+ const path = getSmartQueryByNameApiPath(this.workspaceId, structureSlug, name);
143
+ return this.requestFn<SmartQuery>('GET', path);
144
+ }
145
+
146
+ /**
147
+ * Execute a saved query and return results.
148
+ *
149
+ * Pagination (`limit` / `offset`) is defined inside the persisted
150
+ * `queryDefinition`, not at execution time.
151
+ *
152
+ * Variables in the query body use canonical `${var}` placeholders. Phase 4
153
+ * saved queries declare types for each placeholder via `variables` (see
154
+ * `get()`); the server validates each value against the declared type by
155
+ * JS `typeof` (no coercion — `"123"` is rejected for `number`). Legacy
156
+ * untyped rows accept the same `variables` map but stringify each value
157
+ * before substitution.
158
+ *
159
+ * @param structureSlug - The collection's record slug
160
+ * @param queryId - The saved query UUID
161
+ * @param options - Optional execution options including variables
162
+ * @returns Query results
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * // Simple execution without variables
167
+ * const results = await client.savedQueries.execute('employee', 'query-uuid');
168
+ * console.log('Found:', results.data.length, 'records');
169
+ *
170
+ * // Execution with variables
171
+ * // Query definition: { where: { status: { eq: "${statusFilter}" } } }
172
+ * const filtered = await client.savedQueries.execute('orders', 'query-id', {
173
+ * variables: { statusFilter: 'active' }
174
+ * });
175
+ *
176
+ * // Accessing joined data (nested under _joined)
177
+ * // Query with join: { join: { foreignSlug: "products", ... } }
178
+ * const items = await client.savedQueries.execute('order-items', 'items-with-products');
179
+ * items.data.forEach(item => {
180
+ * console.log('Item:', item.name);
181
+ * console.log('Product:', item._joined?.products?.title);
182
+ * });
183
+ * ```
184
+ */
185
+ public execute<T = any>(
186
+ _structureSlug: string,
187
+ queryId: string,
188
+ options?: ExecuteSmartQueryOptions
189
+ ): Promise<ApiResponse<T[]>> {
190
+ // CEN-1245 — route through the canonical `/saved-queries/:id/execute`
191
+ // endpoint, which returns the contract §9 envelope (`{ data, meta }`).
192
+ // The slug segment is unused by the canonical path; the data service
193
+ // resolves the resource from the saved-query row. The previous legacy
194
+ // slug path returned `{ result: { rows, rowCount } }` and the SDK had
195
+ // to unwrap through `request()`'s `{ result } -> { data: result }`
196
+ // handler — we now stay on the canonical envelope end-to-end.
197
+ const path = getSavedQueryCanonicalExecutePath(this.workspaceId, queryId);
198
+ // Use POST to support variables, with empty body if no options
199
+ const body = options?.variables ? { variables: options.variables } : undefined;
200
+ return this.requestFn<T[]>('POST', path, body);
201
+ }
202
+
203
+ /**
204
+ * Type-safe variant of {@link execute}. The caller passes a generic
205
+ * `TVars` type that describes the saved query's parameter shape, and the
206
+ * compiler enforces that every required key is provided with the right
207
+ * scalar type.
208
+ *
209
+ * Use this when the saved query declares typed `variables` (Phase 4) and
210
+ * the caller has — or can derive — a TypeScript type for the parameter
211
+ * map. For untyped callers, prefer {@link execute}.
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * type MonthlyRevenueVars = { month: Date; region: string };
216
+ *
217
+ * const rows = await client.savedQueries.executeTyped<MonthlyRevenueVars>(
218
+ * 'orders',
219
+ * 'monthly-revenue',
220
+ * { month: new Date('2026-04-01'), region: 'us-east' },
221
+ * );
222
+ *
223
+ * // Compile error — missing required key:
224
+ * // await client.savedQueries.executeTyped<MonthlyRevenueVars>('orders', 'monthly-revenue', { region: 'us-east' });
225
+ * ```
226
+ */
227
+ public executeTyped<TVars extends object, TRow = any>(
228
+ structureSlug: string,
229
+ queryId: string,
230
+ values: TVars,
231
+ ): Promise<ApiResponse<TRow[]>> {
232
+ // Cast to the runtime-values type at the boundary: the network call
233
+ // serializes whatever shape the caller passes (axios JSON-encodes
234
+ // Date as ISO-8601), and the server enforces the typed contract via
235
+ // declarations on the saved-query row. The generic exists purely for
236
+ // call-site type safety, so we deliberately don't constrain `TVars`
237
+ // to `ExecuteSavedQueryValues` — that constraint requires a string
238
+ // index signature that ordinary TS interfaces don't have.
239
+ return this.execute<TRow>(structureSlug, queryId, {
240
+ variables: values as unknown as ExecuteSavedQueryValues,
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Create a new saved query.
246
+ *
247
+ * Routing: when `input.query` (canonical `QueryDefinition`) is provided
248
+ * the SDK calls the canonical `POST /saved-queries` endpoint, which
249
+ * accepts typed `variables` declarations and `${var}` placeholders. When
250
+ * only legacy `input.queryDefinition` is provided the SDK falls back to
251
+ * the slug-based legacy endpoint.
252
+ *
253
+ * @param structureSlug - The collection's record slug. Required for the
254
+ * legacy path; on the canonical path the resource is read from
255
+ * `query.resource` and this slug is currently ignored on the wire.
256
+ * @param input - Canonical (`query`) or legacy (`queryDefinition`) body.
257
+ * @returns The created saved query.
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * // Canonical (Phase 4) — typed parameters + canonical operators
262
+ * const query = await client.savedQueries.create('orders', {
263
+ * name: 'Active Orders',
264
+ * description: 'All orders with active status',
265
+ * query: {
266
+ * resource: 'orders',
267
+ * where: { 'data.status': { eq: '${statusFilter}' } },
268
+ * sort: [{ field: 'createdAt', direction: 'desc' }],
269
+ * page: { limit: 100 },
270
+ * },
271
+ * variables: {
272
+ * statusFilter: { type: 'string', required: true },
273
+ * },
274
+ * });
275
+ * ```
276
+ */
277
+ public create(structureSlug: string, input: CreateSmartQueryInput): Promise<ApiResponse<SmartQuery>> {
278
+ if (input.query) {
279
+ const path = getSavedQueryCanonicalCollectionPath(this.workspaceId);
280
+ const body = {
281
+ name: input.name,
282
+ description: input.description,
283
+ query: input.query,
284
+ variables: input.variables,
285
+ };
286
+ return this.requestFn<SmartQuery>('POST', path, body);
287
+ }
288
+ const path = getSmartQueriesStructureApiPath(this.workspaceId, structureSlug);
289
+ return this.requestFn<SmartQuery>('POST', path, input);
290
+ }
291
+
292
+ /**
293
+ * Update an existing saved query.
294
+ *
295
+ * Routing: when `input.query` is provided the SDK calls canonical
296
+ * `PUT /saved-queries/:id`; otherwise it falls back to the legacy
297
+ * slug-based PUT.
298
+ *
299
+ * @example
300
+ * ```ts
301
+ * const updated = await client.savedQueries.update('orders', 'query-uuid', {
302
+ * name: 'Active Orders v2',
303
+ * query: {
304
+ * resource: 'orders',
305
+ * where: { 'data.status': { in: ['active', 'processing'] } },
306
+ * page: { limit: 200 },
307
+ * },
308
+ * });
309
+ * ```
310
+ */
311
+ public update(structureSlug: string, queryId: string, input: UpdateSmartQueryInput): Promise<ApiResponse<SmartQuery>> {
312
+ if (input.query || input.variables !== undefined) {
313
+ const path = getSavedQueryCanonicalByIdPath(this.workspaceId, queryId);
314
+ const body: Record<string, unknown> = {};
315
+ if (input.name !== undefined) body.name = input.name;
316
+ if (input.description !== undefined) body.description = input.description;
317
+ if (input.query !== undefined) body.query = input.query;
318
+ if (input.variables !== undefined) body.variables = input.variables;
319
+ return this.requestFn<SmartQuery>('PUT', path, body);
320
+ }
321
+ const path = getSmartQueriesStructureApiPath(this.workspaceId, structureSlug, queryId);
322
+ return this.requestFn<SmartQuery>('PUT', path, input);
323
+ }
324
+
325
+ /**
326
+ * Delete a smart query.
327
+ *
328
+ * @param structureSlug - The structure's record slug
329
+ * @param queryId - The smart query UUID
330
+ *
331
+ * @example
332
+ * ```ts
333
+ * await client.smartQueries.delete('orders', 'query-uuid');
334
+ * ```
335
+ */
336
+ public delete(structureSlug: string, queryId: string): Promise<ApiResponse<void>> {
337
+ const path = getSmartQueriesStructureApiPath(this.workspaceId, structureSlug, queryId);
338
+ return this.requestFn<void>('DELETE', path);
339
+ }
340
+
341
+ /**
342
+ * Test-execute a query definition without saving it. Useful for
343
+ * validating syntax, previewing results, and dry-running a draft's
344
+ * typed `variableDeclarations` against runtime `variables`.
345
+ *
346
+ * Routing: when `input.query` (canonical) is provided the SDK calls
347
+ * `POST /saved-queries/test`; otherwise it falls back to the legacy
348
+ * slug-based test endpoint.
349
+ *
350
+ * @example
351
+ * ```ts
352
+ * // Canonical (Phase 4) — typed variable declarations dry-run
353
+ * const result = await client.savedQueries.test('orders', {
354
+ * query: {
355
+ * resource: 'orders',
356
+ * where: { 'data.amount': { gte: '${minTotal}' } },
357
+ * page: { limit: 5 },
358
+ * },
359
+ * variableDeclarations: {
360
+ * minTotal: { type: 'number', required: true },
361
+ * },
362
+ * variables: { minTotal: 100 },
363
+ * });
364
+ * ```
365
+ */
366
+ public test<T = any>(structureSlug: string, input: TestSmartQueryInput): Promise<ApiResponse<T[]>> {
367
+ if (input.query) {
368
+ const path = getSavedQueryCanonicalTestPath(this.workspaceId);
369
+ return this.requestFn<T[]>('POST', path, input);
370
+ }
371
+ const path = getSmartQueryTestApiPath(this.workspaceId, structureSlug);
372
+ return this.requestFn<T[]>('POST', path, input);
373
+ }
374
+ }