@centrali-io/centrali-sdk 5.4.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/query-types.ts ADDED
@@ -0,0 +1,394 @@
1
+ // ---------------------------------------------------------------------------
2
+ // AUTO-GENERATED — DO NOT EDIT BY HAND.
3
+ //
4
+ // Source: services/backend/shared/query/src/types.ts (@centrali/query)
5
+ // Regenerate: `npm run sync:query-types` (also runs on prebuild).
6
+ //
7
+ // The shared package is the single source of truth for canonical query types.
8
+ // This file is a types-only port so the published SDK stays a single npm
9
+ // install. See CEN-1194 for the alignment, CEN-1202 for runtime bundling.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Top-level QueryDefinition (contract §3)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type QueryDefinition = {
17
+ /**
18
+ * Logical resource being queried.
19
+ *
20
+ * - For records: the collection (a.k.a. structure) slug — e.g. `"orders"`,
21
+ * `"customers"`. The records executor treats this as the collection slug.
22
+ * - For other queryable resources: the resource type identifier — e.g.
23
+ * `"audit-log"`, `"function-runs"`, `"files"`, `"webhook-deliveries"`.
24
+ *
25
+ * Named `resource` (not `collection`) because "collection" is overloaded in
26
+ * Centrali — it's the renamed records-bucket entity, so `collection: "audit-log"`
27
+ * would read as a category error. `resource` is REST-canonical and reads
28
+ * truthfully across every executor.
29
+ */
30
+ resource: string;
31
+ where?: WhereExpression;
32
+ text?: TextSearchClause;
33
+ sort?: SortClause[];
34
+ page?: PageClause;
35
+ select?: SelectClause;
36
+ include?: IncludeClause[];
37
+ };
38
+
39
+ export type SavedQueryDefinition = {
40
+ name: string;
41
+ description?: string;
42
+ query: QueryDefinition;
43
+ variables?: Record<string, QueryVariableDefinition>;
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // WhereExpression / FieldCondition (contract §4)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type WhereExpression =
51
+ | FieldConditionMap
52
+ | { and: WhereExpression[] }
53
+ | { or: WhereExpression[] }
54
+ | { not: WhereExpression };
55
+
56
+ export type FieldConditionMap = {
57
+ [field: string]: FieldCondition;
58
+ };
59
+
60
+ /**
61
+ * Exactly-one helper: for a record of operator → value-type, produces a union
62
+ * where each variant has exactly one of the keys present and all the others
63
+ * forbidden via `?: never`. This enforces the contract's "exactly one operator
64
+ * per FieldCondition" rule at the type level — `{ eq: 1, ne: 2 }` fails to
65
+ * type-check.
66
+ */
67
+ type ExactlyOneOperator<T> = {
68
+ [K in keyof T]: { [P in K]: T[P] } & { [P in Exclude<keyof T, K>]?: never };
69
+ }[keyof T];
70
+
71
+ type FieldOperatorMap = {
72
+ eq: ScalarValue;
73
+ ne: ScalarValue;
74
+ gt: number | string;
75
+ gte: number | string;
76
+ lt: number | string;
77
+ lte: number | string;
78
+ in: ScalarValue[];
79
+ nin: ScalarValue[];
80
+ contains: string;
81
+ startsWith: string;
82
+ endsWith: string;
83
+ hasAny: ScalarValue[];
84
+ hasAll: ScalarValue[];
85
+ exists: boolean;
86
+ };
87
+
88
+ export type FieldCondition = ExactlyOneOperator<FieldOperatorMap>;
89
+
90
+ export type ScalarValue = string | number | boolean | null;
91
+
92
+ export type CanonicalOperator =
93
+ | 'eq'
94
+ | 'ne'
95
+ | 'gt'
96
+ | 'gte'
97
+ | 'lt'
98
+ | 'lte'
99
+ | 'in'
100
+ | 'nin'
101
+ | 'contains'
102
+ | 'startsWith'
103
+ | 'endsWith'
104
+ | 'hasAny'
105
+ | 'hasAll'
106
+ | 'exists';
107
+
108
+ export const CANONICAL_OPERATORS: readonly CanonicalOperator[] = [
109
+ 'eq',
110
+ 'ne',
111
+ 'gt',
112
+ 'gte',
113
+ 'lt',
114
+ 'lte',
115
+ 'in',
116
+ 'nin',
117
+ 'contains',
118
+ 'startsWith',
119
+ 'endsWith',
120
+ 'hasAny',
121
+ 'hasAll',
122
+ 'exists',
123
+ ] as const;
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Operator UI metadata (single source of truth for query builders)
127
+ //
128
+ // Centrali's console builders historically maintained their own operator
129
+ // dropdowns — every drift produced a real bug (CEN-1110: visual builder
130
+ // offered `$like`, which the engine never supported, so saved filters
131
+ // silently returned everything). Surfaces consume this map to render labels
132
+ // + value affordances; backends consume it to keep the canonical operator
133
+ // set + UI dropdowns in lock-step.
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /** Argument shape an operator expects in a `FieldCondition`. */
137
+ export type OperatorValueShape =
138
+ /** Single scalar input (string/number/boolean/null). */
139
+ | 'scalar'
140
+ /** Array of scalars — UI typically renders a chip / comma input. */
141
+ | 'array'
142
+ /** Boolean toggle — `exists`. */
143
+ | 'boolean';
144
+
145
+ /**
146
+ * Logical type families an operator applies to. The visual builder narrows
147
+ * the dropdown to the operators valid for the selected field's type.
148
+ *
149
+ * `any` means the operator is type-agnostic (`eq`, `ne`, `exists`).
150
+ */
151
+ export type OperatorTypeApplicability =
152
+ | 'string'
153
+ | 'number'
154
+ | 'boolean'
155
+ | 'datetime'
156
+ | 'array'
157
+ | 'any';
158
+
159
+ export type OperatorMeta = {
160
+ /** Canonical bare operator name. */
161
+ operator: CanonicalOperator;
162
+ /** Human-readable label for dropdowns. */
163
+ label: string;
164
+ /** Short, symbol-style label suitable for compact number/datetime UIs. */
165
+ shortLabel?: string;
166
+ /** Argument shape — drives the value input affordance. */
167
+ valueShape: OperatorValueShape;
168
+ /** Field types this operator applies to. */
169
+ applicableTypes: OperatorTypeApplicability[];
170
+ };
171
+
172
+ export const OPERATOR_METADATA: Readonly<Record<CanonicalOperator, OperatorMeta>> = {
173
+ eq: {
174
+ operator: 'eq',
175
+ label: 'equals',
176
+ shortLabel: '=',
177
+ valueShape: 'scalar',
178
+ applicableTypes: ['any'],
179
+ },
180
+ ne: {
181
+ operator: 'ne',
182
+ label: 'not equals',
183
+ shortLabel: '≠',
184
+ valueShape: 'scalar',
185
+ applicableTypes: ['any'],
186
+ },
187
+ gt: {
188
+ operator: 'gt',
189
+ label: 'greater than',
190
+ shortLabel: '>',
191
+ valueShape: 'scalar',
192
+ applicableTypes: ['number', 'datetime'],
193
+ },
194
+ gte: {
195
+ operator: 'gte',
196
+ label: 'greater or equal',
197
+ shortLabel: '≥',
198
+ valueShape: 'scalar',
199
+ applicableTypes: ['number', 'datetime'],
200
+ },
201
+ lt: {
202
+ operator: 'lt',
203
+ label: 'less than',
204
+ shortLabel: '<',
205
+ valueShape: 'scalar',
206
+ applicableTypes: ['number', 'datetime'],
207
+ },
208
+ lte: {
209
+ operator: 'lte',
210
+ label: 'less or equal',
211
+ shortLabel: '≤',
212
+ valueShape: 'scalar',
213
+ applicableTypes: ['number', 'datetime'],
214
+ },
215
+ in: {
216
+ operator: 'in',
217
+ label: 'in',
218
+ valueShape: 'array',
219
+ applicableTypes: ['string', 'number', 'datetime'],
220
+ },
221
+ nin: {
222
+ operator: 'nin',
223
+ label: 'not in',
224
+ valueShape: 'array',
225
+ applicableTypes: ['string', 'number', 'datetime'],
226
+ },
227
+ contains: {
228
+ operator: 'contains',
229
+ label: 'contains',
230
+ valueShape: 'scalar',
231
+ applicableTypes: ['string'],
232
+ },
233
+ startsWith: {
234
+ operator: 'startsWith',
235
+ label: 'starts with',
236
+ valueShape: 'scalar',
237
+ applicableTypes: ['string'],
238
+ },
239
+ endsWith: {
240
+ operator: 'endsWith',
241
+ label: 'ends with',
242
+ valueShape: 'scalar',
243
+ applicableTypes: ['string'],
244
+ },
245
+ hasAny: {
246
+ operator: 'hasAny',
247
+ label: 'has any of',
248
+ valueShape: 'array',
249
+ applicableTypes: ['array'],
250
+ },
251
+ hasAll: {
252
+ operator: 'hasAll',
253
+ label: 'has all of',
254
+ valueShape: 'array',
255
+ applicableTypes: ['array'],
256
+ },
257
+ exists: {
258
+ operator: 'exists',
259
+ label: 'exists',
260
+ valueShape: 'boolean',
261
+ applicableTypes: ['any'],
262
+ },
263
+ };
264
+
265
+ /**
266
+ * Operators applicable to a given field-type. Used by builder UIs to narrow
267
+ * the dropdown when the user selects a field; falls back to `any`-applicable
268
+ * operators when the type is unknown.
269
+ */
270
+ export function operatorsForFieldType(
271
+ type: OperatorTypeApplicability | string,
272
+ ): readonly OperatorMeta[] {
273
+ const t = type as OperatorTypeApplicability;
274
+ return CANONICAL_OPERATORS.map((op) => OPERATOR_METADATA[op]).filter((meta) =>
275
+ meta.applicableTypes.includes('any') || meta.applicableTypes.includes(t),
276
+ );
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // TextSearchClause (contract §5) — accepted in type, rejected by Phase 1 engine
281
+ // ---------------------------------------------------------------------------
282
+
283
+ export type TextSearchClause = {
284
+ query: string;
285
+ fields?: string[];
286
+ typoTolerance?: boolean;
287
+ };
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // SortClause / PageClause (contract §6)
291
+ // ---------------------------------------------------------------------------
292
+
293
+ export type SortClause = {
294
+ field: string;
295
+ direction: 'asc' | 'desc';
296
+ };
297
+
298
+ /**
299
+ * Two pagination modes. Per contract §6 they are mutually exclusive — `cursor`
300
+ * is forbidden in offset mode and `offset` is forbidden in cursor mode so a
301
+ * caller cannot construct an ambiguous request.
302
+ */
303
+ export type PageClause =
304
+ | { limit: number; offset?: number; cursor?: never }
305
+ | { limit: number; cursor?: string; offset?: never };
306
+
307
+ /**
308
+ * Records Phase 1 page defaults (contract §6).
309
+ * Same default and cap for GET adapter and POST query body.
310
+ */
311
+ export const RECORDS_PAGE_DEFAULT_LIMIT = 50;
312
+ export const RECORDS_PAGE_MAX_LIMIT = 500;
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // SelectClause / IncludeClause (contract §7)
316
+ // ---------------------------------------------------------------------------
317
+
318
+ export type SelectClause = {
319
+ fields: string[];
320
+ };
321
+
322
+ /**
323
+ * Phase 1 reserves the shape but rejects execution with `unsupported_clause`.
324
+ */
325
+ export type IncludeClause = {
326
+ relation: string;
327
+ };
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // QueryVariableDefinition (contract §8) — Phase 4 (saved queries)
331
+ // ---------------------------------------------------------------------------
332
+
333
+ export type VariableType =
334
+ | 'string'
335
+ | 'number'
336
+ | 'boolean'
337
+ | 'datetime'
338
+ | 'id'
339
+ | { array: VariableType }
340
+ | { reference: string };
341
+
342
+ export type QueryVariableDefinition = {
343
+ type: VariableType;
344
+ required?: boolean;
345
+ default?: ScalarValue;
346
+ description?: string;
347
+ };
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // QueryResult envelope (contract §9)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ export type QueryExecutionMode = 'filter' | 'search' | 'hybrid';
354
+
355
+ export type QueryResultMeta = {
356
+ total?: number;
357
+ limit: number;
358
+ offset?: number;
359
+ cursor?: string;
360
+ nextCursor?: string;
361
+ hasMore?: boolean;
362
+ processingTimeMs?: number;
363
+ mode?: QueryExecutionMode;
364
+ };
365
+
366
+ export type QueryResult<T> = {
367
+ data: T[];
368
+ meta: QueryResultMeta;
369
+ };
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Error codes (contract §10–12, §13 reserved-clause behavior)
373
+ // ---------------------------------------------------------------------------
374
+
375
+ export type QueryErrorCode =
376
+ | 'unsupported_clause'
377
+ | 'unsupported_operator'
378
+ | 'unsupported_legacy_operator'
379
+ | 'unreadable_field'
380
+ | 'invalid_query'
381
+ | 'legacy_write_unsupported';
382
+
383
+ export type QueryError = {
384
+ code: QueryErrorCode;
385
+ message: string;
386
+ /** Dotted path inside the QueryDefinition where the error was detected, e.g. "where.data.status.eq". */
387
+ path?: string;
388
+ /** For `unsupported_clause` / `unsupported_operator`, the offending name. */
389
+ clause?: string;
390
+ };
391
+
392
+ export type ValidationResult<T> =
393
+ | { ok: true; value: T }
394
+ | { ok: false; errors: QueryError[] };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Type-only smoke for the canonical query surface added in 5.5.0 (CEN-1194).
3
+ *
4
+ * Runs as part of `npm run build` because it lives in the same project as
5
+ * `index.ts`. Compiling means the canonical types and new methods type-check
6
+ * end-to-end. There is no runtime assertion — when the SDK has a real test
7
+ * harness (separate ticket), these calls will turn into integration tests.
8
+ *
9
+ * To run manually:
10
+ * npx tsc --noEmit scripts/smoke-types.ts
11
+ */
12
+
13
+ import {
14
+ CentraliSDK,
15
+ type QueryDefinition,
16
+ type QueryResult,
17
+ type WhereExpression,
18
+ type SortClause,
19
+ type PageClause,
20
+ type SelectClause,
21
+ type FieldCondition,
22
+ type CanonicalOperator,
23
+ CANONICAL_OPERATORS,
24
+ RECORDS_PAGE_DEFAULT_LIMIT,
25
+ RECORDS_PAGE_MAX_LIMIT,
26
+ } from '../index';
27
+
28
+ // ---- Canonical type ergonomics ----
29
+
30
+ const _operators: readonly CanonicalOperator[] = CANONICAL_OPERATORS;
31
+ const _defaultLimit: number = RECORDS_PAGE_DEFAULT_LIMIT;
32
+ const _maxLimit: number = RECORDS_PAGE_MAX_LIMIT;
33
+
34
+ const _eqOnly: FieldCondition = { eq: 'paid' };
35
+ // Uncommenting the next line MUST fail to compile (exactly-one-operator rule):
36
+ // const _twoOps: FieldCondition = { eq: 'paid', ne: 'cancelled' };
37
+
38
+ const _where: WhereExpression = {
39
+ and: [
40
+ { 'data.status': { eq: 'open' } },
41
+ { or: [
42
+ { 'data.amount': { gte: 100 } },
43
+ { 'data.priority': { eq: 'high' } }
44
+ ]},
45
+ { not: { 'data.archived': { eq: true } } }
46
+ ]
47
+ };
48
+
49
+ const _sort: SortClause[] = [
50
+ { field: 'createdAt', direction: 'desc' },
51
+ { field: 'data.amount', direction: 'asc' }
52
+ ];
53
+
54
+ const _pageOffset: PageClause = { limit: 50, offset: 100 };
55
+ const _pageCursor: PageClause = { limit: 50, cursor: 'abc' };
56
+ // Uncommenting must fail (offset and cursor are mutually exclusive):
57
+ // const _pageBoth: PageClause = { limit: 50, offset: 0, cursor: 'abc' };
58
+
59
+ const _select: SelectClause = { fields: ['id', 'data.status', 'data.customer'] };
60
+
61
+ const _def: QueryDefinition = {
62
+ resource: 'orders',
63
+ where: _where,
64
+ sort: _sort,
65
+ page: _pageOffset,
66
+ select: _select,
67
+ };
68
+
69
+ // ---- New SDK surface ----
70
+
71
+ interface Order {
72
+ id: string;
73
+ data: { status: string; amount: number };
74
+ }
75
+
76
+ async function exerciseSdk() {
77
+ const client = new CentraliSDK({
78
+ baseUrl: 'http://localhost',
79
+ workspaceId: 'demo',
80
+ token: 'test',
81
+ });
82
+
83
+ // RecordsManager.query → QueryResult<T>
84
+ const r1: QueryResult<Order> = await client.records.query<Order>('orders', _def);
85
+ console.log(r1.data.length, r1.meta.limit);
86
+
87
+ // Resource backfill — caller can omit `resource` and we fill it from arg1
88
+ await client.records.query<Order>('orders', { where: _where });
89
+
90
+ // RecordsManager.test → QueryResult<T>
91
+ await client.records.test<Order>('orders', { resource: 'orders' });
92
+
93
+ // RecordsManager.list → ApiResponse<T[]> (legacy GET adapter envelope kept)
94
+ const r2 = await client.records.list<Order>('orders', {
95
+ 'data.status': 'paid',
96
+ sort: '-createdAt',
97
+ });
98
+ console.log(r2.data.length);
99
+
100
+ // RecordsManager.search — text sugar, returns QueryResult<T>
101
+ const r3 = await client.records.search<Order>('orders', 'urgent shipping', {
102
+ page: { limit: 25 },
103
+ });
104
+ console.log(r3.data, r3.meta.mode);
105
+
106
+ // (Saved-query SDK surface — `client.savedQueries` — is deliberately not
107
+ // shipped in 5.5.0. The /saved-queries/* HTTP routes are Phase 4 work
108
+ // tracked in CEN-1198; the canonical SDK manager lands alongside them.
109
+ // Today's `client.smartQueries` namespace is the supported way to call
110
+ // saved queries from the SDK.)
111
+
112
+ // Top-level canonical overload
113
+ const r4: QueryResult<Order> = await client.queryRecords<Order>('orders', {
114
+ resource: 'orders',
115
+ where: { 'data.status': { eq: 'paid' } },
116
+ page: { limit: 100 },
117
+ });
118
+ console.log(r4.data, r4.meta);
119
+
120
+ // Legacy GET-adapter form still type-checks (and emits a one-shot warn at runtime)
121
+ const r5 = await client.queryRecords<Order>('orders', {
122
+ 'data.status': 'paid',
123
+ sort: '-createdAt',
124
+ });
125
+ console.log(r5.data);
126
+ }
127
+
128
+ void exerciseSdk;
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const sdkRoot = resolve(__dirname, '..');
8
+ const sharedQueryTypes = resolve(
9
+ sdkRoot,
10
+ '../../backend/shared/query/src/types.ts'
11
+ );
12
+ const outputFile = resolve(sdkRoot, 'query-types.ts');
13
+
14
+ const HEADER = `// ---------------------------------------------------------------------------
15
+ // AUTO-GENERATED — DO NOT EDIT BY HAND.
16
+ //
17
+ // Source: services/backend/shared/query/src/types.ts (@centrali/query)
18
+ // Regenerate: \`npm run sync:query-types\` (also runs on prebuild).
19
+ //
20
+ // The shared package is the single source of truth for canonical query types.
21
+ // This file is a types-only port so the published SDK stays a single npm
22
+ // install. See CEN-1194 for the alignment, CEN-1202 for runtime bundling.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ `;
26
+
27
+ const source = readFileSync(sharedQueryTypes, 'utf8');
28
+
29
+ // Drop the original file's leading docblock (kept inside HEADER's pointer).
30
+ const stripped = source.replace(/^\/\*\*[\s\S]*?\*\/\s*\n/, '');
31
+
32
+ mkdirSync(dirname(outputFile), { recursive: true });
33
+ writeFileSync(outputFile, HEADER + stripped, 'utf8');
34
+
35
+ console.log(
36
+ `[sync-query-types] wrote ${outputFile.replace(sdkRoot + '/', '')} from ${sharedQueryTypes.replace(
37
+ resolve(sdkRoot, '../../..') + '/',
38
+ ''
39
+ )}`
40
+ );