@objectstack/client 0.6.1 → 0.7.2

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/src/index.ts CHANGED
@@ -1,4 +1,22 @@
1
1
  import { QueryAST, SortNode, AggregationNode, WindowFunctionNode } from '@objectstack/spec/data';
2
+ import {
3
+ BatchUpdateRequest,
4
+ BatchUpdateResponse,
5
+ UpdateManyRequest,
6
+ DeleteManyRequest,
7
+ BatchOptions,
8
+ MetadataCacheRequest,
9
+ MetadataCacheResponse,
10
+ StandardErrorCode,
11
+ ErrorCategory,
12
+ CreateViewRequest,
13
+ UpdateViewRequest,
14
+ ListViewsRequest,
15
+ SavedView,
16
+ ListViewsResponse,
17
+ ViewResponse
18
+ } from '@objectstack/spec/api';
19
+ import { Logger, createLogger } from '@objectstack/core';
2
20
 
3
21
  export interface ClientConfig {
4
22
  baseUrl: string;
@@ -7,6 +25,14 @@ export interface ClientConfig {
7
25
  * Custom fetch implementation (e.g. node-fetch or for Next.js caching)
8
26
  */
9
27
  fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
28
+ /**
29
+ * Logger instance for debugging
30
+ */
31
+ logger?: Logger;
32
+ /**
33
+ * Enable debug logging
34
+ */
35
+ debug?: boolean;
10
36
  }
11
37
 
12
38
  export interface DiscoveryResult {
@@ -36,22 +62,42 @@ export interface PaginatedResult<T = any> {
36
62
  count: number;
37
63
  }
38
64
 
65
+ export interface StandardError {
66
+ code: StandardErrorCode;
67
+ message: string;
68
+ category: ErrorCategory;
69
+ httpStatus: number;
70
+ retryable: boolean;
71
+ details?: Record<string, any>;
72
+ }
73
+
39
74
  export class ObjectStackClient {
40
75
  private baseUrl: string;
41
76
  private token?: string;
42
77
  private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
43
78
  private routes?: DiscoveryResult['routes'];
79
+ private logger: Logger;
44
80
 
45
81
  constructor(config: ClientConfig) {
46
82
  this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
47
83
  this.token = config.token;
48
84
  this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
85
+
86
+ // Initialize logger
87
+ this.logger = config.logger || createLogger({
88
+ level: config.debug ? 'debug' : 'info',
89
+ format: 'pretty'
90
+ });
91
+
92
+ this.logger.debug('ObjectStack client created', { baseUrl: this.baseUrl });
49
93
  }
50
94
 
51
95
  /**
52
96
  * Initialize the client by discovering server capabilities and routes.
53
97
  */
54
98
  async connect() {
99
+ this.logger.debug('Connecting to ObjectStack server', { baseUrl: this.baseUrl });
100
+
55
101
  try {
56
102
  // Connect to the discovery endpoint
57
103
  // During boot, we might not know routes, so we check convention /api/v1 first
@@ -59,9 +105,15 @@ export class ObjectStackClient {
59
105
 
60
106
  const data = await res.json();
61
107
  this.routes = data.routes;
108
+
109
+ this.logger.info('Connected to ObjectStack server', {
110
+ routes: Object.keys(data.routes || {}),
111
+ capabilities: data.capabilities
112
+ });
113
+
62
114
  return data as DiscoveryResult;
63
115
  } catch (e) {
64
- console.error('Failed to connect to ObjectStack Server', e);
116
+ this.logger.error('Failed to connect to ObjectStack server', e as Error, { baseUrl: this.baseUrl });
65
117
  throw e;
66
118
  }
67
119
  }
@@ -76,6 +128,51 @@ export class ObjectStackClient {
76
128
  return res.json();
77
129
  },
78
130
 
131
+ /**
132
+ * Get object metadata with cache support
133
+ * Supports ETag-based conditional requests for efficient caching
134
+ */
135
+ getCached: async (name: string, cacheOptions?: MetadataCacheRequest): Promise<MetadataCacheResponse> => {
136
+ const route = this.getRoute('metadata');
137
+ const headers: Record<string, string> = {};
138
+
139
+ if (cacheOptions?.ifNoneMatch) {
140
+ headers['If-None-Match'] = cacheOptions.ifNoneMatch;
141
+ }
142
+ if (cacheOptions?.ifModifiedSince) {
143
+ headers['If-Modified-Since'] = cacheOptions.ifModifiedSince;
144
+ }
145
+
146
+ const res = await this.fetch(`${this.baseUrl}${route}/object/${name}`, {
147
+ headers
148
+ });
149
+
150
+ // Check for 304 Not Modified
151
+ if (res.status === 304) {
152
+ return {
153
+ notModified: true,
154
+ etag: cacheOptions?.ifNoneMatch ? {
155
+ value: cacheOptions.ifNoneMatch.replace(/^W\/|"/g, ''),
156
+ weak: cacheOptions.ifNoneMatch.startsWith('W/')
157
+ } : undefined
158
+ };
159
+ }
160
+
161
+ const data = await res.json();
162
+ const etag = res.headers.get('ETag');
163
+ const lastModified = res.headers.get('Last-Modified');
164
+
165
+ return {
166
+ data,
167
+ etag: etag ? {
168
+ value: etag.replace(/^W\/|"/g, ''),
169
+ weak: etag.startsWith('W/')
170
+ } : undefined,
171
+ lastModified: lastModified || undefined,
172
+ notModified: false
173
+ };
174
+ },
175
+
79
176
  getView: async (object: string, type: 'list' | 'form' = 'list') => {
80
177
  const route = this.getRoute('ui');
81
178
  const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`);
@@ -170,7 +267,7 @@ export class ObjectStackClient {
170
267
 
171
268
  createMany: async <T = any>(object: string, data: Partial<T>[]): Promise<T[]> => {
172
269
  const route = this.getRoute('data');
173
- const res = await this.fetch(`${this.baseUrl}${route}/${object}/batch`, {
270
+ const res = await this.fetch(`${this.baseUrl}${route}/${object}/createMany`, {
174
271
  method: 'POST',
175
272
  body: JSON.stringify(data)
176
273
  });
@@ -186,13 +283,38 @@ export class ObjectStackClient {
186
283
  return res.json();
187
284
  },
188
285
 
189
- updateMany: async <T = any>(object: string, data: Partial<T>, filters?: Record<string, any> | any[]): Promise<number> => {
286
+ /**
287
+ * Batch update multiple records
288
+ * Uses the new BatchUpdateRequest schema with full control over options
289
+ */
290
+ batch: async (object: string, request: BatchUpdateRequest): Promise<BatchUpdateResponse> => {
190
291
  const route = this.getRoute('data');
191
292
  const res = await this.fetch(`${this.baseUrl}${route}/${object}/batch`, {
192
- method: 'PATCH',
193
- body: JSON.stringify({ data, filters })
293
+ method: 'POST',
294
+ body: JSON.stringify(request)
194
295
  });
195
- return res.json(); // Returns count
296
+ return res.json();
297
+ },
298
+
299
+ /**
300
+ * Update multiple records (simplified batch update)
301
+ * Convenience method for batch updates without full BatchUpdateRequest
302
+ */
303
+ updateMany: async <T = any>(
304
+ object: string,
305
+ records: Array<{ id: string; data: Partial<T> }>,
306
+ options?: BatchOptions
307
+ ): Promise<BatchUpdateResponse> => {
308
+ const route = this.getRoute('data');
309
+ const request: UpdateManyRequest = {
310
+ records,
311
+ options
312
+ };
313
+ const res = await this.fetch(`${this.baseUrl}${route}/${object}/updateMany`, {
314
+ method: 'POST',
315
+ body: JSON.stringify(request)
316
+ });
317
+ return res.json();
196
318
  },
197
319
 
198
320
  delete: async (object: string, id: string): Promise<{ success: boolean }> => {
@@ -203,16 +325,121 @@ export class ObjectStackClient {
203
325
  return res.json();
204
326
  },
205
327
 
206
- deleteMany: async(object: string, filters?: Record<string, any> | any[]): Promise<{ count: number }> => {
328
+ /**
329
+ * Delete multiple records by IDs
330
+ */
331
+ deleteMany: async(object: string, ids: string[], options?: BatchOptions): Promise<BatchUpdateResponse> => {
207
332
  const route = this.getRoute('data');
208
- const res = await this.fetch(`${this.baseUrl}${route}/${object}/batch`, {
209
- method: 'DELETE',
210
- body: JSON.stringify({ filters })
333
+ const request: DeleteManyRequest = {
334
+ ids,
335
+ options
336
+ };
337
+ const res = await this.fetch(`${this.baseUrl}${route}/${object}/deleteMany`, {
338
+ method: 'POST',
339
+ body: JSON.stringify(request)
211
340
  });
212
341
  return res.json();
213
342
  }
214
343
  };
215
344
 
345
+ /**
346
+ * View Storage Operations
347
+ * Save, load, and manage UI view configurations
348
+ */
349
+ views = {
350
+ /**
351
+ * Create a new saved view
352
+ */
353
+ create: async (request: CreateViewRequest): Promise<ViewResponse> => {
354
+ const route = this.getRoute('ui');
355
+ const res = await this.fetch(`${this.baseUrl}${route}/views`, {
356
+ method: 'POST',
357
+ body: JSON.stringify(request)
358
+ });
359
+ return res.json();
360
+ },
361
+
362
+ /**
363
+ * Get a saved view by ID
364
+ */
365
+ get: async (id: string): Promise<ViewResponse> => {
366
+ const route = this.getRoute('ui');
367
+ const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`);
368
+ return res.json();
369
+ },
370
+
371
+ /**
372
+ * List saved views with optional filters
373
+ */
374
+ list: async (request?: ListViewsRequest): Promise<ListViewsResponse> => {
375
+ const route = this.getRoute('ui');
376
+ const queryParams = new URLSearchParams();
377
+
378
+ if (request?.object) queryParams.set('object', request.object);
379
+ if (request?.type) queryParams.set('type', request.type);
380
+ if (request?.visibility) queryParams.set('visibility', request.visibility);
381
+ if (request?.createdBy) queryParams.set('createdBy', request.createdBy);
382
+ if (request?.isDefault !== undefined) queryParams.set('isDefault', String(request.isDefault));
383
+ if (request?.limit) queryParams.set('limit', String(request.limit));
384
+ if (request?.offset) queryParams.set('offset', String(request.offset));
385
+
386
+ const url = queryParams.toString()
387
+ ? `${this.baseUrl}${route}/views?${queryParams.toString()}`
388
+ : `${this.baseUrl}${route}/views`;
389
+
390
+ const res = await this.fetch(url);
391
+ return res.json();
392
+ },
393
+
394
+ /**
395
+ * Update an existing view
396
+ */
397
+ update: async (request: UpdateViewRequest): Promise<ViewResponse> => {
398
+ const route = this.getRoute('ui');
399
+ const { id, ...updateData } = request;
400
+ const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`, {
401
+ method: 'PATCH',
402
+ body: JSON.stringify(updateData)
403
+ });
404
+ return res.json();
405
+ },
406
+
407
+ /**
408
+ * Delete a saved view
409
+ */
410
+ delete: async (id: string): Promise<{ success: boolean }> => {
411
+ const route = this.getRoute('ui');
412
+ const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`, {
413
+ method: 'DELETE'
414
+ });
415
+ return res.json();
416
+ },
417
+
418
+ /**
419
+ * Share a view with users/teams
420
+ */
421
+ share: async (id: string, userIds: string[]): Promise<ViewResponse> => {
422
+ const route = this.getRoute('ui');
423
+ const res = await this.fetch(`${this.baseUrl}${route}/views/${id}/share`, {
424
+ method: 'POST',
425
+ body: JSON.stringify({ sharedWith: userIds })
426
+ });
427
+ return res.json();
428
+ },
429
+
430
+ /**
431
+ * Set a view as default for an object
432
+ */
433
+ setDefault: async (id: string, object: string): Promise<ViewResponse> => {
434
+ const route = this.getRoute('ui');
435
+ const res = await this.fetch(`${this.baseUrl}${route}/views/${id}/set-default`, {
436
+ method: 'POST',
437
+ body: JSON.stringify({ object })
438
+ });
439
+ return res.json();
440
+ }
441
+ };
442
+
216
443
  /**
217
444
  * Private Helpers
218
445
  */
@@ -225,6 +452,12 @@ export class ObjectStackClient {
225
452
  }
226
453
 
227
454
  private async fetch(url: string, options: RequestInit = {}): Promise<Response> {
455
+ this.logger.debug('HTTP request', {
456
+ method: options.method || 'GET',
457
+ url,
458
+ hasBody: !!options.body
459
+ });
460
+
228
461
  const headers: Record<string, string> = {
229
462
  'Content-Type': 'application/json',
230
463
  ...(options.headers as Record<string, string> || {}),
@@ -236,14 +469,41 @@ export class ObjectStackClient {
236
469
 
237
470
  const res = await this.fetchImpl(url, { ...options, headers });
238
471
 
472
+ this.logger.debug('HTTP response', {
473
+ method: options.method || 'GET',
474
+ url,
475
+ status: res.status,
476
+ ok: res.ok
477
+ });
478
+
239
479
  if (!res.ok) {
240
- let errorBody;
480
+ let errorBody: any;
241
481
  try {
242
482
  errorBody = await res.json();
243
483
  } catch {
244
484
  errorBody = { message: res.statusText };
245
485
  }
246
- throw new Error(`[ObjectStack] Request failed: ${res.status} ${JSON.stringify(errorBody)}`);
486
+
487
+ this.logger.error('HTTP request failed', undefined, {
488
+ method: options.method || 'GET',
489
+ url,
490
+ status: res.status,
491
+ error: errorBody
492
+ });
493
+
494
+ // Create a standardized error if the response includes error details
495
+ const errorMessage = errorBody?.message || errorBody?.error?.message || res.statusText;
496
+ const errorCode = errorBody?.code || errorBody?.error?.code;
497
+ const error = new Error(`[ObjectStack] ${errorCode ? `${errorCode}: ` : ''}${errorMessage}`) as any;
498
+
499
+ // Attach error details for programmatic access
500
+ error.code = errorCode;
501
+ error.category = errorBody?.category;
502
+ error.httpStatus = res.status;
503
+ error.retryable = errorBody?.retryable;
504
+ error.details = errorBody?.details || errorBody;
505
+
506
+ throw error;
247
507
  }
248
508
 
249
509
  return res;
@@ -252,9 +512,40 @@ export class ObjectStackClient {
252
512
  private getRoute(key: keyof DiscoveryResult['routes']): string {
253
513
  if (!this.routes) {
254
514
  // Fallback for strictness, but we allow bootstrapping
255
- console.warn(`[ObjectStackClient] Accessing ${key} route before connect(). Using default /api/v1/${key}`);
515
+ this.logger.warn('Accessing route before connect()', {
516
+ route: key,
517
+ fallback: `/api/v1/${key}`
518
+ });
256
519
  return `/api/v1/${key}`;
257
520
  }
258
521
  return this.routes[key] || `/api/v1/${key}`;
259
522
  }
260
523
  }
524
+
525
+ // Re-export type-safe query builder
526
+ export { QueryBuilder, FilterBuilder, createQuery, createFilter } from './query-builder';
527
+
528
+ // Re-export commonly used types from @objectstack/spec/api for convenience
529
+ export type {
530
+ BatchUpdateRequest,
531
+ BatchUpdateResponse,
532
+ UpdateManyRequest,
533
+ DeleteManyRequest,
534
+ BatchOptions,
535
+ BatchRecord,
536
+ BatchOperationResult,
537
+ MetadataCacheRequest,
538
+ MetadataCacheResponse,
539
+ StandardErrorCode,
540
+ ErrorCategory,
541
+ CreateViewRequest,
542
+ UpdateViewRequest,
543
+ ListViewsRequest,
544
+ SavedView,
545
+ ViewResponse,
546
+ ListViewsResponse,
547
+ ViewType,
548
+ ViewVisibility,
549
+ ViewColumn,
550
+ ViewLayout
551
+ } from '@objectstack/spec/api';
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Type-Safe Query Builder
3
+ *
4
+ * Provides a fluent API for building ObjectStack queries with:
5
+ * - Compile-time type checking
6
+ * - Intelligent code completion
7
+ * - Runtime validation
8
+ * - Type-safe filters and selections
9
+ */
10
+
11
+ import { QueryAST, FilterCondition, SortNode } from '@objectstack/spec/data';
12
+
13
+ /**
14
+ * Type-safe filter builder
15
+ */
16
+ export class FilterBuilder<T = any> {
17
+ private conditions: FilterCondition[] = [];
18
+
19
+ /**
20
+ * Equality filter: field = value
21
+ */
22
+ equals<K extends keyof T>(field: K, value: T[K]): this {
23
+ this.conditions.push([field as string, '=', value]);
24
+ return this;
25
+ }
26
+
27
+ /**
28
+ * Not equals filter: field != value
29
+ */
30
+ notEquals<K extends keyof T>(field: K, value: T[K]): this {
31
+ this.conditions.push([field as string, '!=', value]);
32
+ return this;
33
+ }
34
+
35
+ /**
36
+ * Greater than filter: field > value
37
+ */
38
+ greaterThan<K extends keyof T>(field: K, value: T[K]): this {
39
+ this.conditions.push([field as string, '>', value]);
40
+ return this;
41
+ }
42
+
43
+ /**
44
+ * Greater than or equal filter: field >= value
45
+ */
46
+ greaterThanOrEqual<K extends keyof T>(field: K, value: T[K]): this {
47
+ this.conditions.push([field as string, '>=', value]);
48
+ return this;
49
+ }
50
+
51
+ /**
52
+ * Less than filter: field < value
53
+ */
54
+ lessThan<K extends keyof T>(field: K, value: T[K]): this {
55
+ this.conditions.push([field as string, '<', value]);
56
+ return this;
57
+ }
58
+
59
+ /**
60
+ * Less than or equal filter: field <= value
61
+ */
62
+ lessThanOrEqual<K extends keyof T>(field: K, value: T[K]): this {
63
+ this.conditions.push([field as string, '<=', value]);
64
+ return this;
65
+ }
66
+
67
+ /**
68
+ * IN filter: field IN (value1, value2, ...)
69
+ */
70
+ in<K extends keyof T>(field: K, values: T[K][]): this {
71
+ this.conditions.push([field as string, 'in', values]);
72
+ return this;
73
+ }
74
+
75
+ /**
76
+ * NOT IN filter: field NOT IN (value1, value2, ...)
77
+ */
78
+ notIn<K extends keyof T>(field: K, values: T[K][]): this {
79
+ this.conditions.push([field as string, 'not_in', values]);
80
+ return this;
81
+ }
82
+
83
+ /**
84
+ * LIKE filter: field LIKE pattern
85
+ */
86
+ like<K extends keyof T>(field: K, pattern: string): this {
87
+ this.conditions.push([field as string, 'like', pattern]);
88
+ return this;
89
+ }
90
+
91
+ /**
92
+ * IS NULL filter: field IS NULL
93
+ */
94
+ isNull<K extends keyof T>(field: K): this {
95
+ this.conditions.push([field as string, 'is_null', null]);
96
+ return this;
97
+ }
98
+
99
+ /**
100
+ * IS NOT NULL filter: field IS NOT NULL
101
+ */
102
+ isNotNull<K extends keyof T>(field: K): this {
103
+ this.conditions.push([field as string, 'is_not_null', null]);
104
+ return this;
105
+ }
106
+
107
+ /**
108
+ * Build the filter condition
109
+ */
110
+ build(): FilterCondition {
111
+ if (this.conditions.length === 0) {
112
+ throw new Error('Filter builder has no conditions');
113
+ }
114
+ if (this.conditions.length === 1) {
115
+ return this.conditions[0];
116
+ }
117
+ // Combine multiple conditions with AND
118
+ return ['and', ...this.conditions];
119
+ }
120
+
121
+ /**
122
+ * Get raw conditions array
123
+ */
124
+ getConditions(): FilterCondition[] {
125
+ return this.conditions;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Type-safe query builder
131
+ */
132
+ export class QueryBuilder<T = any> {
133
+ private query: Partial<QueryAST> = {};
134
+ private _object: string;
135
+
136
+ constructor(object: string) {
137
+ this._object = object;
138
+ this.query.object = object;
139
+ }
140
+
141
+ /**
142
+ * Select specific fields
143
+ */
144
+ select<K extends keyof T>(...fields: K[]): this {
145
+ this.query.fields = fields as string[];
146
+ return this;
147
+ }
148
+
149
+ /**
150
+ * Add filters using a builder function
151
+ */
152
+ where(builderFn: (builder: FilterBuilder<T>) => void): this {
153
+ const builder = new FilterBuilder<T>();
154
+ builderFn(builder);
155
+ const conditions = builder.getConditions();
156
+
157
+ if (conditions.length === 1) {
158
+ this.query.where = conditions[0];
159
+ } else if (conditions.length > 1) {
160
+ this.query.where = ['and', ...conditions] as FilterCondition;
161
+ }
162
+
163
+ return this;
164
+ }
165
+
166
+ /**
167
+ * Add raw filter condition
168
+ */
169
+ filter(condition: FilterCondition): this {
170
+ this.query.where = condition;
171
+ return this;
172
+ }
173
+
174
+ /**
175
+ * Sort by fields
176
+ */
177
+ orderBy<K extends keyof T>(field: K, order: 'asc' | 'desc' = 'asc'): this {
178
+ if (!this.query.orderBy) {
179
+ this.query.orderBy = [];
180
+ }
181
+ (this.query.orderBy as SortNode[]).push({
182
+ field: field as string,
183
+ order
184
+ });
185
+ return this;
186
+ }
187
+
188
+ /**
189
+ * Limit the number of results
190
+ */
191
+ limit(count: number): this {
192
+ this.query.limit = count;
193
+ return this;
194
+ }
195
+
196
+ /**
197
+ * Skip records (for pagination)
198
+ */
199
+ skip(count: number): this {
200
+ this.query.offset = count;
201
+ return this;
202
+ }
203
+
204
+ /**
205
+ * Paginate results
206
+ */
207
+ paginate(page: number, pageSize: number): this {
208
+ this.query.limit = pageSize;
209
+ this.query.offset = (page - 1) * pageSize;
210
+ return this;
211
+ }
212
+
213
+ /**
214
+ * Group by fields
215
+ */
216
+ groupBy<K extends keyof T>(...fields: K[]): this {
217
+ this.query.groupBy = fields as string[];
218
+ return this;
219
+ }
220
+
221
+ /**
222
+ * Build the final query AST
223
+ */
224
+ build(): QueryAST {
225
+ return {
226
+ object: this._object,
227
+ ...this.query
228
+ } as QueryAST;
229
+ }
230
+
231
+ /**
232
+ * Get the current query state
233
+ */
234
+ getQuery(): Partial<QueryAST> {
235
+ return { ...this.query };
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Create a type-safe query builder for an object
241
+ */
242
+ export function createQuery<T = any>(object: string): QueryBuilder<T> {
243
+ return new QueryBuilder<T>(object);
244
+ }
245
+
246
+ /**
247
+ * Create a type-safe filter builder
248
+ */
249
+ export function createFilter<T = any>(): FilterBuilder<T> {
250
+ return new FilterBuilder<T>();
251
+ }