diff --git a/packages/client/README.md b/packages/client/README.md index ae40491a1..f14383c15 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -6,7 +6,11 @@ The official TypeScript client for ObjectStack. - **Auto-Discovery**: Connects to your ObjectStack server and automatically configures API endpoints. - **Typed Metadata**: Retrieve Object and View definitions with full type support. +- **Metadata Caching**: ETag-based conditional requests for efficient metadata caching. - **Unified Data Access**: Simple CRUD operations for any object in your schema. +- **Batch Operations**: Efficient bulk create/update/delete with transaction support. +- **View Storage**: Save, load, and share custom UI view configurations. +- **Standardized Errors**: Machine-readable error codes with retry guidance. ## Installation @@ -47,8 +51,48 @@ async function main() { priority: 1 }); - // 6. Batch Operations - await client.data.deleteMany('todo_task', ['id1', 'id2']); + // 6. Batch Operations (New!) + const batchResult = await client.data.batch('todo_task', { + operation: 'update', + records: [ + { id: '1', data: { status: 'active' } }, + { id: '2', data: { status: 'active' } } + ], + options: { + atomic: true, // Rollback on any failure + returnRecords: true // Include full records in response + } + }); + console.log(`Updated ${batchResult.succeeded} records`); + + // 7. Metadata Caching (New!) + const cachedObject = await client.meta.getCached('todo_task', { + ifNoneMatch: '"686897696a7c876b7e"' // ETag from previous request + }); + if (cachedObject.notModified) { + console.log('Using cached metadata'); + } + + // 8. View Storage (New!) + const view = await client.views.create({ + name: 'active_tasks', + label: 'Active Tasks', + object: 'todo_task', + type: 'list', + visibility: 'public', + query: { + object: 'todo_task', + where: { status: 'active' }, + orderBy: [{ field: 'priority', order: 'desc' }], + limit: 50 + }, + layout: { + columns: [ + { field: 'subject', label: 'Task', width: 200 }, + { field: 'priority', label: 'Priority', width: 100 } + ] + } + }); } ``` @@ -59,6 +103,7 @@ Initializes the client by fetching the system discovery manifest from `/api/v1`. ### `client.meta` - `getObject(name: string)`: Get object schema. +- `getCached(name: string, options?)`: Get object schema with ETag-based caching. - `getView(name: string)`: Get view configuration. ### `client.data` @@ -66,11 +111,21 @@ Initializes the client by fetching the system discovery manifest from `/api/v1`. - `get(object, id)`: Get single record by ID. - `query(object, ast)`: Execute complex query using full AST. - `create(object, data)`: Create record. -- `createMany(object, data[])`: Batch create records. +- `batch(object, request)`: **Recommended** - Execute batch operations (create/update/upsert/delete) with full control. +- `createMany(object, data[])`: Batch create records (convenience method). - `update(object, id, data)`: Update record. -- `updateMany(object, ids[], data)`: Batch update records. +- `updateMany(object, records[], options?)`: Batch update records (convenience method). - `delete(object, id)`: Delete record. -- `deleteMany(object, ids[])`: Batch delete records. +- `deleteMany(object, ids[], options?)`: Batch delete records (convenience method). + +### `client.views` (New!) +- `create(request)`: Create a new saved view. +- `get(id)`: Get a saved view by ID. +- `list(request?)`: List saved views with optional filters. +- `update(request)`: Update an existing view. +- `delete(id)`: Delete a saved view. +- `share(id, userIds[])`: Share a view with users/teams. +- `setDefault(id, object)`: Set a view as default for an object. ### Query Options The `find` method accepts an options object: @@ -80,3 +135,32 @@ The `find` method accepts an options object: - `top`: Limit number of records. - `skip`: Offset for pagination. +### Batch Options +Batch operations support the following options: +- `atomic`: If true, rollback entire batch on any failure (default: true). +- `returnRecords`: If true, return full record data in response (default: false). +- `continueOnError`: If true (and atomic=false), continue processing remaining records after errors. +- `validateOnly`: If true, validate records without persisting changes (dry-run mode). + +### Error Handling +The client provides standardized error handling with machine-readable error codes: + +```typescript +try { + await client.data.create('todo_task', { subject: '' }); +} catch (error) { + console.error('Error code:', error.code); // e.g., 'validation_error' + console.error('Category:', error.category); // e.g., 'validation' + console.error('HTTP status:', error.httpStatus); // e.g., 400 + console.error('Retryable:', error.retryable); // e.g., false + console.error('Details:', error.details); // Additional error info +} +``` + +Common error codes: +- `validation_error`: Input validation failed +- `unauthenticated`: Authentication required +- `permission_denied`: Insufficient permissions +- `resource_not_found`: Resource does not exist +- `rate_limit_exceeded`: Too many requests + diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index da8ba87e4..ceb5b9ec7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,21 @@ import { QueryAST, SortNode, AggregationNode, WindowFunctionNode } from '@objectstack/spec/data'; +import { + BatchUpdateRequest, + BatchUpdateResponse, + UpdateManyRequest, + DeleteManyRequest, + BatchOptions, + MetadataCacheRequest, + MetadataCacheResponse, + StandardErrorCode, + ErrorCategory, + CreateViewRequest, + UpdateViewRequest, + ListViewsRequest, + SavedView, + ListViewsResponse, + ViewResponse +} from '@objectstack/spec/api'; import { Logger, createLogger } from '@objectstack/core'; export interface ClientConfig { @@ -45,6 +62,15 @@ export interface PaginatedResult { count: number; } +export interface StandardError { + code: StandardErrorCode; + message: string; + category: ErrorCategory; + httpStatus: number; + retryable: boolean; + details?: Record; +} + export class ObjectStackClient { private baseUrl: string; private token?: string; @@ -102,6 +128,51 @@ export class ObjectStackClient { return res.json(); }, + /** + * Get object metadata with cache support + * Supports ETag-based conditional requests for efficient caching + */ + getCached: async (name: string, cacheOptions?: MetadataCacheRequest): Promise => { + const route = this.getRoute('metadata'); + const headers: Record = {}; + + if (cacheOptions?.ifNoneMatch) { + headers['If-None-Match'] = cacheOptions.ifNoneMatch; + } + if (cacheOptions?.ifModifiedSince) { + headers['If-Modified-Since'] = cacheOptions.ifModifiedSince; + } + + const res = await this.fetch(`${this.baseUrl}${route}/object/${name}`, { + headers + }); + + // Check for 304 Not Modified + if (res.status === 304) { + return { + notModified: true, + etag: cacheOptions?.ifNoneMatch ? { + value: cacheOptions.ifNoneMatch.replace(/^W\/|"/g, ''), + weak: cacheOptions.ifNoneMatch.startsWith('W/') + } : undefined + }; + } + + const data = await res.json(); + const etag = res.headers.get('ETag'); + const lastModified = res.headers.get('Last-Modified'); + + return { + data, + etag: etag ? { + value: etag.replace(/^W\/|"/g, ''), + weak: etag.startsWith('W/') + } : undefined, + lastModified: lastModified || undefined, + notModified: false + }; + }, + getView: async (object: string, type: 'list' | 'form' = 'list') => { const route = this.getRoute('ui'); const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`); @@ -196,7 +267,7 @@ export class ObjectStackClient { createMany: async (object: string, data: Partial[]): Promise => { const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${object}/batch`, { + const res = await this.fetch(`${this.baseUrl}${route}/${object}/createMany`, { method: 'POST', body: JSON.stringify(data) }); @@ -212,13 +283,38 @@ export class ObjectStackClient { return res.json(); }, - updateMany: async (object: string, data: Partial, filters?: Record | any[]): Promise => { + /** + * Batch update multiple records + * Uses the new BatchUpdateRequest schema with full control over options + */ + batch: async (object: string, request: BatchUpdateRequest): Promise => { const route = this.getRoute('data'); const res = await this.fetch(`${this.baseUrl}${route}/${object}/batch`, { - method: 'PATCH', - body: JSON.stringify({ data, filters }) + method: 'POST', + body: JSON.stringify(request) }); - return res.json(); // Returns count + return res.json(); + }, + + /** + * Update multiple records (simplified batch update) + * Convenience method for batch updates without full BatchUpdateRequest + */ + updateMany: async ( + object: string, + records: Array<{ id: string; data: Partial }>, + options?: BatchOptions + ): Promise => { + const route = this.getRoute('data'); + const request: UpdateManyRequest = { + records, + options + }; + const res = await this.fetch(`${this.baseUrl}${route}/${object}/updateMany`, { + method: 'POST', + body: JSON.stringify(request) + }); + return res.json(); }, delete: async (object: string, id: string): Promise<{ success: boolean }> => { @@ -229,16 +325,121 @@ export class ObjectStackClient { return res.json(); }, - deleteMany: async(object: string, filters?: Record | any[]): Promise<{ count: number }> => { + /** + * Delete multiple records by IDs + */ + deleteMany: async(object: string, ids: string[], options?: BatchOptions): Promise => { const route = this.getRoute('data'); - const res = await this.fetch(`${this.baseUrl}${route}/${object}/batch`, { - method: 'DELETE', - body: JSON.stringify({ filters }) + const request: DeleteManyRequest = { + ids, + options + }; + const res = await this.fetch(`${this.baseUrl}${route}/${object}/deleteMany`, { + method: 'POST', + body: JSON.stringify(request) }); return res.json(); } }; + /** + * View Storage Operations + * Save, load, and manage UI view configurations + */ + views = { + /** + * Create a new saved view + */ + create: async (request: CreateViewRequest): Promise => { + const route = this.getRoute('ui'); + const res = await this.fetch(`${this.baseUrl}${route}/views`, { + method: 'POST', + body: JSON.stringify(request) + }); + return res.json(); + }, + + /** + * Get a saved view by ID + */ + get: async (id: string): Promise => { + const route = this.getRoute('ui'); + const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`); + return res.json(); + }, + + /** + * List saved views with optional filters + */ + list: async (request?: ListViewsRequest): Promise => { + const route = this.getRoute('ui'); + const queryParams = new URLSearchParams(); + + if (request?.object) queryParams.set('object', request.object); + if (request?.type) queryParams.set('type', request.type); + if (request?.visibility) queryParams.set('visibility', request.visibility); + if (request?.createdBy) queryParams.set('createdBy', request.createdBy); + if (request?.isDefault !== undefined) queryParams.set('isDefault', String(request.isDefault)); + if (request?.limit) queryParams.set('limit', String(request.limit)); + if (request?.offset) queryParams.set('offset', String(request.offset)); + + const url = queryParams.toString() + ? `${this.baseUrl}${route}/views?${queryParams.toString()}` + : `${this.baseUrl}${route}/views`; + + const res = await this.fetch(url); + return res.json(); + }, + + /** + * Update an existing view + */ + update: async (request: UpdateViewRequest): Promise => { + const route = this.getRoute('ui'); + const { id, ...updateData } = request; + const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`, { + method: 'PATCH', + body: JSON.stringify(updateData) + }); + return res.json(); + }, + + /** + * Delete a saved view + */ + delete: async (id: string): Promise<{ success: boolean }> => { + const route = this.getRoute('ui'); + const res = await this.fetch(`${this.baseUrl}${route}/views/${id}`, { + method: 'DELETE' + }); + return res.json(); + }, + + /** + * Share a view with users/teams + */ + share: async (id: string, userIds: string[]): Promise => { + const route = this.getRoute('ui'); + const res = await this.fetch(`${this.baseUrl}${route}/views/${id}/share`, { + method: 'POST', + body: JSON.stringify({ sharedWith: userIds }) + }); + return res.json(); + }, + + /** + * Set a view as default for an object + */ + setDefault: async (id: string, object: string): Promise => { + const route = this.getRoute('ui'); + const res = await this.fetch(`${this.baseUrl}${route}/views/${id}/set-default`, { + method: 'POST', + body: JSON.stringify({ object }) + }); + return res.json(); + } + }; + /** * Private Helpers */ @@ -276,7 +477,7 @@ export class ObjectStackClient { }); if (!res.ok) { - let errorBody; + let errorBody: any; try { errorBody = await res.json(); } catch { @@ -290,7 +491,19 @@ export class ObjectStackClient { error: errorBody }); - throw new Error(`[ObjectStack] Request failed: ${res.status} ${JSON.stringify(errorBody)}`); + // Create a standardized error if the response includes error details + const errorMessage = errorBody?.message || errorBody?.error?.message || res.statusText; + const errorCode = errorBody?.code || errorBody?.error?.code; + const error = new Error(`[ObjectStack] ${errorCode ? `${errorCode}: ` : ''}${errorMessage}`) as any; + + // Attach error details for programmatic access + error.code = errorCode; + error.category = errorBody?.category; + error.httpStatus = res.status; + error.retryable = errorBody?.retryable; + error.details = errorBody?.details || errorBody; + + throw error; } return res; @@ -308,3 +521,28 @@ export class ObjectStackClient { return this.routes[key] || `/api/v1/${key}`; } } + +// Re-export commonly used types from @objectstack/spec/api for convenience +export type { + BatchUpdateRequest, + BatchUpdateResponse, + UpdateManyRequest, + DeleteManyRequest, + BatchOptions, + BatchRecord, + BatchOperationResult, + MetadataCacheRequest, + MetadataCacheResponse, + StandardErrorCode, + ErrorCategory, + CreateViewRequest, + UpdateViewRequest, + ListViewsRequest, + SavedView, + ViewResponse, + ListViewsResponse, + ViewType, + ViewVisibility, + ViewColumn, + ViewLayout +} from '@objectstack/spec/api';