{"id":31798322,"url":"https://github.com/datavysta/vysta-client","last_synced_at":"2026-01-20T17:30:35.254Z","repository":{"id":267979623,"uuid":"902968659","full_name":"datavysta/vysta-client","owner":"datavysta","description":"TypeScript client for Vysta APIs","archived":false,"fork":false,"pushed_at":"2025-08-14T18:27:44.000Z","size":320,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-09-19T06:56:14.425Z","etag":null,"topics":["api-client","datavysta","rest-api","typescript","vysta"],"latest_commit_sha":null,"homepage":"https://www.datavysta.com","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/datavysta.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2024-12-13T16:37:56.000Z","updated_at":"2025-08-14T18:27:47.000Z","dependencies_parsed_at":null,"dependency_job_id":"272bb43a-f525-411b-b43f-675900410c9c","html_url":"https://github.com/datavysta/vysta-client","commit_stats":null,"previous_names":["datavysta/vysta-client"],"tags_count":49,"template":false,"template_full_name":null,"purl":"pkg:github/datavysta/vysta-client","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/datavysta%2Fvysta-client","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/datavysta%2Fvysta-client/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/datavysta%2Fvysta-client/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/datavysta%2Fvysta-client/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/datavysta","download_url":"https://codeload.github.com/datavysta/vysta-client/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/datavysta%2Fvysta-client/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":279005416,"owners_count":26083883,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-10T02:00:06.843Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api-client","datavysta","rest-api","typescript","vysta"],"created_at":"2025-10-10T21:22:44.913Z","updated_at":"2025-10-10T21:22:49.635Z","avatar_url":"https://github.com/datavysta.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Vysta Client\n\nA strongly-typed TypeScript client for Vysta APIs.\n\n## Installation\n\n```bash\nnpm install @datavysta/vysta-client\n```\n\n## Usage\n\n### Basic Setup\n\n```typescript\nimport { VystaClient } from '@datavysta/vysta-client';\n\n// Initialize the client\nconst client = new VystaClient({\n  baseUrl: 'https://api.datavysta.com',\n  debug: true\n});\n\n// Login\nawait client.login('username', 'password');\n```\n\n**Multi-tenant / custom host header**\n\nIf your Vysta deployment differentiates tenants by the incoming host name you can forward that value with every request (including authentication) via the optional `host` setting:\n\n```typescript\nconst client = new VystaClient({\n  baseUrl: 'https://api.datavysta.com',\n  host: 'my-tenant.example.com'   // sent as X-DataVysta-Host on every call\n});\n\n// You can also change it later:\nclient.setHost('other-tenant.example.com');\n```\n\nThe library automatically adds an `X-DataVysta-Host` header to all requests whenever `host` is set.\n\nIf your frontend is already served from the tenant-specific domain (e.g. `https://my-tenant.example.com`) you don't need to set `host` at all—the backend will automatically use the request's `Origin`/`Host` header to resolve the tenant.\n\n### Token Storage\n\nBy default, tokens are stored in localStorage. You can customize this behavior:\n\n```typescript\nimport { TokenStorage, TokenKey } from '@datavysta/vysta-client';\n\n// Custom token storage using localStorage\nconst myStorage: TokenStorage = {\n  setToken(key: TokenKey, value: string) {\n    localStorage.setItem(key, value);\n  },\n  getToken(key: TokenKey) {\n    return localStorage.getItem(key);\n  },\n  clearTokens() {\n    localStorage.clear();\n  }\n};\n\n// Custom error handling\nconst myErrorHandler = {\n  onError(error: Error) {\n    console.error('Auth error:', error);\n    // Redirect to login page, show notification, etc.\n  }\n};\n\n// Initialize client with custom storage\nconst client = new VystaClient({\n  baseUrl: 'https://api.datavysta.com',\n  storage: myStorage,\n  errorHandler: myErrorHandler\n});\n```\n\n### Caching\n\nThe Vysta client ships with a lightweight, **range-aware cache** (similar in spirit to React-Query) that can dramatically cut round-trips when you scroll, paginate or repeatedly query the same data.\n\n• Default storage is **IndexedDB** in the browser (via the [`idb`](https://github.com/jakearchibald/idb) helper) and an in-memory `Map` in Node.js.  \n• Caching is **opt-in at the GET query level** – you can configure it per client or swap in your own `CacheStorage` implementation.\n\n```typescript\nimport { VystaClient, DefaultCacheStorage } from '@datavysta/vysta-client';\n\n// 1) Use default cache with default config (TTL = 5 min, maxSize = 1000 entries)\nconst client = new VystaClient({\n  baseUrl: 'https://api.datavysta.com'\n});\n\n// 2) Fine-tune TTL / size\nconst client = new VystaClient({\n  baseUrl: '…',\n  cache: {\n    ttl: 10 * 60 * 1000,   // 10 minutes\n    maxSize: 500           // keep newest 500 entries (LRU)\n  }\n});\n\n// 3) Provide a completely custom cache backend\nclass RedisCache implements CacheStorage { /* … */ }\nconst client = new VystaClient({\n  baseUrl: '…',\n  cache: new RedisCache()\n});\n```\n\n#### Per-request cache control\n```typescript\n// Enable cache for specific requests (cache is opt-in)\nawait service.getAll({ limit: 10, useCache: true });\nawait service.query({ conditions: [...], useCache: true });\nawait service.getById(123, true); // second parameter enables cache\n\n// Disable cache for specific requests\nawait service.getAll({ limit: 10, useCache: false });\nawait service.query({ conditions: [...], useCache: false });\nawait service.getById(123, false); // second parameter disables cache\n\n// Default behavior (no cache specified)\nawait service.getAll({ limit: 10 }); // no cache used\nawait service.getById(123); // no cache used\n```\n\n#### Manual cache control\n```typescript\nawait client.clearCache();                              // everything\nawait client.clearCacheForEntity('Northwinds', 'Products');\n\nconst products = new ProductService(client);\nawait products.refreshCache();                          // this service only\n```\n\n#### How range-aware caching works\n1. **One entry per logical query** – the cache key uses connection + entity + method (`getAll`, `query`, …) + _filters/sort_.  \n   Pagination values (`offset`, `limit`) are _excluded_, so all pages merge into the same entry.\n2. Each entry stores: `records[]`, `loadedRanges[]` and `totalCount`.  \n3. When you fetch e.g. rows `40-60`, the cache either:\n   • slices and returns instantly if that range is already covered, or  \n   • fetches only the missing rows, merges them into the entry, and updates the range list.\n4. Subsequent requests for any previously-loaded window are served in-memory (≈1-2 ms).\n\nThis strategy means that after a single linear scroll through an infinite-scroll grid, **no further network calls are made** for that dataset until the TTL expires or you invalidate the cache.\n\n\u003e **Note**: If you bundle for browsers you'll need `idb` as a dependency, which is already declared in `peerDependencies`. Simply `npm install` in your project and most bundlers will include it automatically.\n\n### Creating a Service\n\nThe library provides two base service classes:\n\n#### VystaReadonlyService\nFor read-only data sources that don't support CRUD operations, such as views, aggregations, or reports:\n\n```typescript\n// Define your entity type\ninterface CustomerSummary {\n  customerId?: string;\n  count?: number;\n  companyName?: string;\n}\n\n// Create a read-only service for a view/aggregation\nexport class CustomerSummaryService extends VystaReadonlyService\u003cCustomerSummary\u003e {\n  constructor(client: VystaClient) {\n    super(client, 'Northwinds', 'CustomerSummary');\n  }\n}\n\n// Use the service for querying\nconst summaries = new CustomerSummaryService(client);\nconst result = await summaries.getAll({\n  filters: {\n    count: { gt: 0 }\n  },\n  order: {\n    companyName: 'asc'\n  }\n});\n```\n\n#### VystaService\nFor full CRUD operations on entities with primary keys:\n\n```typescript\n// Define your entity type\ninterface Product {\n  productId: number;\n  productName: string;\n  unitPrice: number;\n  unitsInStock: number;\n  discontinued: boolean;\n}\n\n// Create a service class for your entity\nexport class ProductService extends VystaService\u003cProduct\u003e {\n  constructor(client: VystaClient) {\n    super(client, 'Northwinds', 'Products', {\n      primaryKey: 'productId'\n    });\n  }\n}\n\n// Initialize the service\nconst products = new ProductService(client);\n\n// Use CRUD operations\nconst product = await products.getById(1);\nconsole.log(`Product: ${product.productName}`);\n\n// Query active products\nconst activeProducts = await products.getAll({\n  filters: {\n    discontinued: { eq: false }\n  },\n  order: {\n    unitPrice: 'desc'\n  }\n});\n\n// Update price and stock levels\nawait products.update(1, { unitPrice: 29.99 });\nawait products.delete(1);\n```\n\n### Query Operations\n\nBoth service types support the same querying capabilities:\n\n```typescript\n// Basic query\nconst result = await summaries.getAll();\nconsole.log('Summaries:', result.data);\nconsole.log('Total count:', result.count);\n\n// Simple filter\nconst activeProducts = await products.getAll({\n  filters: {\n    discontinued: { eq: false }\n  }\n});\n\n// Complex query with multiple filters, sorting, and pagination\nconst customerReport = await summaries.getAll({\n  // Select specific fields\n  select: ['customerId', 'companyName', 'count'],\n  \n  // Multiple filters\n  filters: {\n    count: { gt: 5 },\n    companyName: { like: '%Ltd%' }\n  },\n  \n  // Sort by multiple fields\n  order: {\n    count: 'desc',\n    companyName: 'asc'\n  },\n  \n  // Pagination\n  limit: 10,\n  offset: 0,\n  \n  // Include total record count\n  recordCount: true\n});\n\n// Response includes data and total count\nconsole.log('Matching customers:', customerReport.data.length);\nconsole.log('Total matches:', customerReport.count);\n```\n\n### **Download Operations**\n\nThe `download` method allows you to download data in CSV or Excel formats based on query parameters.\n\n```typescript\n// Download CSV file\nconst csvBlob = await summaries.download({\n  filters: {\n    discontinued: { eq: false }\n  }\n}, FileType.CSV);\n\n// Download Excel file\nconst excelBlob = await summaries.download({\n  filters: {\n    discontinued: { eq: false }\n  }\n}, FileType.EXCEL);\n```\n\n### Hydration\n\nServices support hydration to add computed properties to each row by overriding the `hydrate` method:\n\n\u003e **Note:** Vysta recommends prefixing calculated fields with an underscore (_) to distinguish them from persisted fields. This helps exclude them from various operations like updates and filtering.\n\n```typescript\n// Basic example with customer names\ninterface Customer {\n  firstName: string;\n  lastName: string;\n}\n\ninterface CustomerWithFullName extends Customer {\n  _fullName: string;  // Calculated field prefixed with _\n}\n\nclass CustomerService extends VystaReadonlyService\u003cCustomer, CustomerWithFullName\u003e {\n  constructor(client: VystaClient) {\n    super(client, 'Northwinds', 'Customers');\n  }\n\n  protected override hydrate(customer: Customer): CustomerWithFullName {\n    return {\n      ...customer,\n      _fullName: `${customer.firstName} ${customer.lastName}`\n    };\n  }\n}\n\n// Advanced example with calculated values\ninterface Product {\n  productId: number;\n  productName: string;\n  unitPrice: number;\n  unitsInStock: number;\n  discontinued: boolean;\n}\n\ninterface ProductWithValue extends Product {\n  _totalStockValue: number;  // Calculated field prefixed with _\n}\n\nclass ProductService extends VystaService\u003cProduct, ProductWithValue\u003e {\n  constructor(client: VystaClient) {\n    super(client, 'Northwinds', 'Products', {\n      primaryKey: 'productId'\n    });\n  }\n\n  protected override hydrate(product: Product): ProductWithValue {\n    return {\n      ...product,\n      _totalStockValue: product.unitPrice * product.unitsInStock\n    };\n  }\n}\n\n// Use hydrated values in queries\nconst products = new ProductService(client);\nconst expensiveStock = await products.getAll({\n  filters: {\n    _totalStockValue: { gt: 1000 },\n    discontinued: { eq: false }\n  },\n  order: {\n    _totalStockValue: 'desc'\n  }\n});\n```\n\n### Query Parameters\n\nThe following query parameters are supported:\n\n- `limit`: Maximum number of records to return\n- `offset`: Number of records to skip\n- `order`: Object specifying field and direction ('asc' or 'desc')\n- `filter`: Filter expression\n- `recordCount`: Set to true to include total record count in response header\n- `q`: Optional search term for full-text search across relevant fields\n- `select`: Specifies which fields to include in the response. Can be used in multiple ways:\n    - As an array: `select=id,name`\n    - As an object mapping field names to custom labels: `select=id=no,name=label`\n    - You can mix and match both formats: `select=id,name=label`\n    - The order in which fields appear in `select` determines their order in the response.\n\nExample:\n```typescript\nconst params = {\n  limit: 10,\n  offset: 0,\n  order: {\n    productId: 'desc'\n  },\n  filter: 'category eq \"Beverages\"',\n  recordCount: true,  // Optional: Include total record count\n  q: 'chai'  // Optional: Search for products containing 'chai'\n};\n\nconst result = await service.getAll(params);\n```\n\n### Response\n\nThe response will include:\n- The requested records as an array\n- The total number of records matching the query (when `recordCount: true` is specified)\n\n### Response Types\n\nThe `getAll()` method returns a `DataResult\u003cT\u003e` object:\n```typescript\ninterface DataResult\u003cT\u003e {\n  data: T[];        // Array of records\n  count: number;    // Total record count (-1 if not available)\n  error: Error | null;\n}\n```\n\nThe `count` field will be:\n- The total number of records matching the query when `recordCount: true`\n- `-1` when the count is not available\n- Useful for pagination and infinite scroll implementations\n\n### Content Type Handling\n\nThe client automatically handles different response types based on the Content-Type header:\n\n- `application/json`: Responses are parsed as JSON\n- `text/plain` and other text formats: Responses are returned as string\n- Other formats: Responses are returned as Blob\n\nThis allows the same API methods to work with different response formats, making it more flexible when dealing with APIs that might return different types of data.\n\nExplore live demonstrations in our [Examples Hub](examples/index.html), showcasing various client features.\n\n### CRUD Operations\n\n```typescript\n// Create\nconst newProduct = await products.create({\n  productId: 78,\n  productName: 'New Product',\n  unitPrice: 29.99,\n  unitsInStock: 100,\n  discontinued: false\n});\n\n// Update\nawait products.update(78, {\n  unitPrice: 39.99,\n  unitsInStock: 50\n});\n\n// Delete\nawait products.delete(78);\n\n// Bulk operations\nawait products.updateWhere(\n  { filters: { discontinued: { eq: false } } },\n  { unitsInStock: 0 }\n);\n\nawait products.deleteWhere({\n  filters: { discontinued: { eq: true } }\n});\n```\n\n### File Upload Service\n\nThe `VystaFileService` provides file upload capabilities using TUS protocol:\n\n```typescript\nimport { VystaClient, VystaFileService } from '@datavysta/vysta-client';\nimport Uppy from '@uppy/core';\nimport Tus from '@uppy/tus';\n\n// Create a file service\nexport class NorthwindFileService extends VystaFileService {\n  constructor(client: VystaClient) {\n    super(client, 'NorthwindFile');\n  }\n}\n\n// Initialize the service\nconst northwindFileService = new NorthwindFileService(client);\n\n// Create an Uppy instance for file upload\nconst uppy = new Uppy()\n  .use(Tus, {\n    ...(await northwindFileService.getTusXhrOptions())\n  });\n\n// Handle file upload\nuppy.on('upload-success', async (file, response) =\u003e {\n  // Register the uploaded file with Vysta\n  await northwindFileService.registerUploadedFile({\n    path: '/',\n    id: file.id,\n    name: file.name\n  });\n});\n\n// Add files and start upload\nuppy.addFile({\n  name: 'product-image.jpg',\n  type: 'image/jpeg',\n  data: new Blob(['file contents'])\n});\nuppy.upload();\n```\n\n## Admin Services\n\n### User Administration\n\nThe `VystaAdminUserService` provides methods for managing users, including creating, listing, updating, deleting users, as well as managing invitations and password resets.\n\n```typescript\nimport { VystaClient, VystaAdminUserService } from '@datavysta/vysta-client';\n\nconst client = new VystaClient({ baseUrl: 'http://localhost:8080' });\nconst userService = new VystaAdminUserService(client);\n\n// List users\nconst users = await userService.listUsers();\n\n// Get available roles\nconst roleService = new VystaRoleService(client);\nconst roles = await roleService.getAllRoles();\nconst userRoleId = roles.find(r =\u003e r.name === 'User')?.id;\nconst adminRoleId = roles.find(r =\u003e r.name === 'Admin')?.id;\n\n// Create a new user\nconst newUser = await userService.createUser({\n  name: 'John Smith',\n  email: 'john.smith@example.com',\n  roleIds: [userRoleId], // UUID from the roles list\n  forceChange: true\n});\n\n// Create a user with custom redirect URL for invitation emails\nconst newUserWithRedirect = await userService.createUser({\n  name: 'Jane Doe',\n  email: 'jane.doe@example.com',\n  roleIds: [userRoleId],\n  forceChange: true,\n  invitationRedirectUrl: '/custom/onboarding' // Custom path for invitation acceptance\n});\n\n// Update a user\nawait userService.updateUser(userId, {\n  name: 'Jane Doe',\n  email: 'jane@example.com',\n  roleIds: [adminRoleId] // UUID from the roles list\n});\n\n// User management operations\nawait userService.resendInvitation(userId);  // Resend invitation email\nawait userService.sendInvitation(userId);     // Send a new invitation\nawait userService.forgotPassword(userId);     // Send password reset link\nconst inviteLink = await userService.copyInvitation(userId);  // Get invitation link to copy/share\nawait userService.revokeByUserId(userId);     // Delete/revoke a user\n```\n\n#### User Type\n```typescript\ninterface User {\n  id: string;\n  name: string;\n  email: string;\n  phoneNumber?: string;\n  // The API returns roleIds and roleNames as comma-delimited strings\n  roleIds: string;\n  roleNames: string;\n  // Array versions of the roles\n  roleIdsArray: string[];\n  roleNamesArray: string[];\n  invitationId?: string;\n  forceChange: boolean;\n  disabled: boolean;\n  createdOn: string;\n  modifiedOn: string;\n  properties?: string;\n  password?: string; // Only used when creating/updating users\n}\n\n// For creating users, use this structure\ninterface CreateUserData {\n  name?: string;\n  email?: string;\n  roleIds?: string[]; // Array of role UUIDs\n  phoneNumber?: string;\n  forceChange?: boolean;\n  properties?: string;\n  password?: string;\n  invitationRedirectUrl?: string; // Optional custom redirect URL for invitation emails\n}\n```\n\n### Role Management\n\nThe `VystaRoleService` allows you to fetch all roles defined in the system.\n\n```typescript\nimport { VystaClient, VystaRoleService, Role } from '@datavysta/vysta-client';\n\nconst client = new VystaClient({ baseUrl: 'http://localhost:8080' });\nconst roleService = new VystaRoleService(client);\n\nconst roles: Role[] = await roleService.getAllRoles();\nroles.forEach(role =\u003e {\n  // Role IDs are UUIDs\n  console.log(role.id, role.name, role.description);\n});\n```\n\n#### Role Type\n```typescript\nexport interface Role {\n  id: string; // UUID\n  name: string;\n  description?: string;\n}\n```\n\n### Permissions\n\nThe `VystaPermissionService` provides methods to fetch permissions for connections, tables, views, queries, procedures, workflows, and filesystems.\n\n```typescript\nimport { VystaClient, VystaPermissionService, ObjectPermission } from '@datavysta/vysta-client';\n\nconst client = new VystaClient({ baseUrl: 'http://localhost:8080' });\nconst permissionService = new VystaPermissionService(client);\n\n// Get permissions for a connection\nconst perms: ObjectPermission = await permissionService.getConnectionPermissions('Northwinds');\n\n// Get permissions for a table\nconst tablePerms: ObjectPermission = await permissionService.getTablePermissions('Northwinds', 'region');\n\n// Check if the user can select from a connection\nconst canSelect = await permissionService.canSelectConnection('Northwinds');\nif (canSelect) {\n  // User can select from this connection\n  console.log('User CAN select from Northwinds');\n} else {\n  console.log('User CANNOT select from Northwinds');\n}\n```\n\n#### ObjectPermission Type\n```typescript\nexport interface ObjectPermission {\n  id: string;\n  children: ObjectPermission[];\n  where: any;\n  grants: string[];\n}\n```\n\n### Timezone Service\n\nThe `VystaTimezoneService` lets you retrieve the list of supported time-zones for your UI or settings pages.\n\n```typescript\nconst timezones = await new VystaTimezoneService(client).getAllTimezones();\n// → [{ id: 'Europe/Paris', displayName: 'Paris (Europe)' }, …]\n```\n\nAuthentication is required; otherwise the call throws \"Not authenticated\".\n\n## Query Operators\n\nThe following operators are supported for filtering:\n\n- `eq` - Equal\n- `neq` - Not Equal\n- `gt` - Greater Than\n- `gte` - Greater Than or Equal\n- `lt` - Less Than\n- `lte` - Less Than or Equal\n- `like` - Pattern Matching\n- `nlike` - Negative Pattern Match\n- `in` - In Array\n- `nin` - Not In Array\n- `isnull` - Is NULL\n- `isnotnull` - Is Not NULL\n\n## Authentication\n\n### Methods\n\n| Method | Description | Parameters | Returns |\n|--------|-------------|------------|---------|\n| `login` | Authenticates with username/password | `username: string`\u003cbr\u003e`password: string` | `Promise\u003cAuthResult\u003e` |\n| `logout` | Logs out and clears auth data | none | `void` |\n| `getSignInMethods` | Gets available OAuth providers | none | `Promise\u003cSignInInfo[]\u003e` |\n| `getAuthorizeUrl` | Gets OAuth authorization URL | `signInId: string` | `Promise\u003cstring\u003e` |\n| `exchangeToken` | Exchanges OAuth token for auth result | `token: string` | `Promise\u003cAuthResult\u003e` |\n| `getUserProfile` | Gets authenticated user's profile | none | `Promise\u003cUserProfile\u003e` |\n\n### Types\n\n```typescript\ninterface UserProfile {\n  name: string;\n  email: string | null;\n  emailVerifiedOn: string | null;\n  phoneNumber: string | null;\n  phoneNumberVerifiedOn: string | null;\n  apiKeyCreatedOn: string | null;\n}\n\ninterface SignInInfo {\n  id: string;    // Provider ID (e.g., 'okta')\n  name: string;  // Display name (e.g., 'Okta')\n}\n\ninterface AuthResult {\n  accessToken: string;\n  refreshToken: string;\n  host: string;\n}\n```\n\n### Example Usage\n\n```typescript\n// Password authentication\nconst result = await client.login('user@example.com', 'password');\n\n// OAuth authentication\nconst providers = await client.getSignInMethods();\nconst authUrl = await client.getAuthorizeUrl(providers[0].id);\n// Redirect user to authUrl\nwindow.location.href = authUrl;\n\n// Handle OAuth redirect\nconst urlParams = new URLSearchParams(window.location.search);\nconst token = urlParams.get('Token');  // Token parameter name may vary by provider\nconst redirectUrl = urlParams.get('RedirectUrl');  // Original redirect URL from provider\nconst authResult = await client.exchangeToken(token);\n\n// After successful authentication, redirect to the provided URL or your default\nif (redirectUrl) {\n  window.location.replace(redirectUrl);\n}\n```\n\n\u003e Note: Your application is responsible for handling the OAuth redirect. The provider will return both a token and a redirect URL. \n\u003e For Okta, the redirect will typically be to `/authenticationRedirect` on your application's domain. After exchanging the token, \n\u003e you should redirect the user to the provided URL or your application's default location. Both OAuth and password authentication \n\u003e result in the same authenticated state, allowing you to make API calls with the client.\n\n## Password Reset and Invitation Authentication\n\nThe Vysta client provides comprehensive support for password reset and invitation workflows. These endpoints work without authentication, allowing users to reset their passwords or accept invitations when they're not logged in.\n\n### Methods\n\n| Method | Description | Parameters | Returns |\n|--------|-------------|------------|---------|\n| `forgotPassword` | Initiates password reset for user | `email: string` | `Promise\u003cForgotPasswordResponse\u003e` |\n| `validateCode` | Validates reset code or invitation | `params: ValidateCodeParams` | `Promise\u003cValidateCodeResponse\u003e` |\n| `validateInvitation` | Validates invitation using just ID | `params: ValidateInvitationParams` | `Promise\u003cValidateInvitationResponse\u003e` |\n| `changePassword` | Changes password using reset code | `params: ChangePasswordParams` | `Promise\u003cChangePasswordResponse\u003e` |\n| `acceptInvitation` | Accepts invitation and sets password | `params: AcceptInvitationParams` | `Promise\u003cAcceptInvitationResponse\u003e` |\n\n### Types and Enums\n\n```typescript\n// Status enum for password reset operations\nenum PasswordResetStatus {\n  VALID = 0,\n  EXPIRED = 1,\n  NOT_FOUND = 2,\n  INVALID_CODE = 3,\n  COMPLETED = 4,\n  PASSWORDS_MUST_MATCH = 5,\n  PASSWORD_REUSED = 6,\n}\n\n// Status enum for invitation operations\nenum InvitationStatus {\n  VALID = 0,\n  EXPIRED = 1,\n  NOT_FOUND = 2,\n  ALREADY_ACCEPTED = 3,\n}\n\n\n\n// Forgot Password\ninterface ForgotPasswordResponse {\n  exists: boolean;  // Whether the user exists in the system\n}\n\n// Validate Code\ninterface ValidateCodeParams {\n  email: string;           // Required for password reset\n  code: string;            // Required for password reset\n}\n\ninterface ValidateCodeResponse {\n  status: PasswordResetStatus;\n}\n\n// Validate Invitation\ninterface ValidateInvitationParams {\n  id: string;\n}\n\ninterface ValidateInvitationResponse {\n  status: InvitationStatus;\n}\n\n// Change Password\ninterface ChangePasswordParams {\n  email: string;\n  code: string;\n  password: string;\n  passwordConfirmed: string;\n}\n\ninterface ChangePasswordResponse {\n  status: PasswordResetStatus;\n  authenticationResult?: AuthResult;  // Optional auto-login\n}\n\n// Accept Invitation\ninterface AcceptInvitationParams {\n  id: string;\n  password: string;\n  passwordConfirmed: string;\n}\n\ninterface AcceptInvitationResponse {\n  status: PasswordResetStatus;\n  authenticationResult?: AuthResult;  // Optional auto-login\n}\n```\n\n### Password Reset Workflow\n\nComplete password reset flow with validation and error handling:\n\n```typescript\nimport { \n  VystaClient, \n  PasswordResetStatus,\n  InvitationStatus\n} from '@datavysta/vysta-client';\n\nconst client = new VystaClient({\n  baseUrl: 'https://api.example.com'\n});\n\nasync function resetPassword(email: string, newPassword: string) {\n  try {\n    // Step 1: Initiate password reset\n    const forgotResponse = await client.forgotPassword(email);\n    \n    if (!forgotResponse.exists) {\n      throw new Error('User not found');\n    }\n    \n    console.log('Password reset email sent');\n    \n    // Step 2: User receives email with reset code\n    // In a real app, you would get this from user input\n    const resetCode = 'code-from-email';\n    \n    // Step 3: Validate the reset code\n    const validateResponse = await client.validateCode({\n      email: email,\n      code: resetCode\n    });\n    \n    if (validateResponse.status !== PasswordResetStatus.VALID) {\n      const statusMessages = {\n        [PasswordResetStatus.INVALID_CODE]: 'Invalid reset code',\n        [PasswordResetStatus.EXPIRED]: 'Reset code has expired',\n        [PasswordResetStatus.COMPLETED]: 'Reset code already used',\n        [PasswordResetStatus.NOT_FOUND]: 'Reset code not found',\n        [PasswordResetStatus.PASSWORDS_MUST_MATCH]: 'Passwords must match',\n        [PasswordResetStatus.PASSWORD_REUSED]: 'Cannot reuse previous password'\n      };\n      throw new Error(statusMessages[validateResponse.status] || 'Unknown error');\n    }\n    \n    // Step 4: Change password\n    const changeResponse = await client.changePassword({\n      email: email,\n      code: resetCode,\n      password: newPassword,\n      passwordConfirmed: newPassword\n    });\n    \n    if (changeResponse.status === PasswordResetStatus.VALID || changeResponse.status === PasswordResetStatus.COMPLETED) {\n      console.log('Password changed successfully');\n      \n      // Check if user was automatically logged in\n      if (changeResponse.authenticationResult) {\n        console.log('User automatically logged in');\n        // Client is now authenticated and ready to make API calls\n      }\n    }\n    \n  } catch (error) {\n    console.error('Password reset failed:', error.message);\n    throw error;\n  }\n}\n\n// Usage\nawait resetPassword('user@example.com', 'newSecurePassword123');\n```\n\n### Invitation Acceptance Workflow\n\nComplete invitation acceptance flow:\n\n```typescript\nasync function acceptInvitation(invitationId: string, password: string) {\n  try {\n    // Step 1: Validate the invitation\n    const validateResponse = await client.validateInvitation({\n      id: invitationId\n    });\n    \n    if (validateResponse.status !== InvitationStatus.VALID) {\n      const statusMessages = {\n        [InvitationStatus.EXPIRED]: 'Invitation has expired',\n        [InvitationStatus.NOT_FOUND]: 'Invitation not found',\n        [InvitationStatus.ALREADY_ACCEPTED]: 'Invitation already accepted'\n      };\n      throw new Error(statusMessages[validateResponse.status] || 'Unknown error');\n    }\n    \n    // Step 2: Accept invitation and set password\n    const acceptResponse = await client.acceptInvitation({\n      id: invitationId,\n      password: password,\n      passwordConfirmed: password\n    });\n    \n    if (acceptResponse.status === PasswordResetStatus.VALID || acceptResponse.status === PasswordResetStatus.COMPLETED) {\n      console.log('Invitation accepted successfully');\n      \n      // Check if user was automatically logged in\n      if (acceptResponse.authenticationResult) {\n        console.log('User automatically logged in');\n        // Client is now authenticated and ready to make API calls\n      }\n    }\n    \n  } catch (error) {\n    console.error('Invitation acceptance failed:', error.message);\n    throw error;\n  }\n}\n\n// Usage\nawait acceptInvitation('invitation-uuid-123', 'newSecurePassword123');\n```\n\n### Error Handling\n\nBoth password reset and invitation flows include comprehensive error handling:\n\n```typescript\nasync function handlePasswordResetErrors() {\n  try {\n    // For password reset code validation\n    const response = await client.validateCode({\n      email: 'user@example.com',\n      code: 'some-code'\n    });\n    \n    switch (response.status) {\n      case PasswordResetStatus.VALID:\n        console.log('Code is valid, proceed with password change');\n        break;\n      case PasswordResetStatus.INVALID_CODE:\n        console.log('Invalid code, please check and try again');\n        break;\n      case PasswordResetStatus.EXPIRED:\n        console.log('Code has expired, request a new one');\n        break;\n      case PasswordResetStatus.COMPLETED:\n        console.log('Code already used, request a new one');\n        break;\n      case PasswordResetStatus.NOT_FOUND:\n        console.log('Code not found, request a new one');\n        break;\n      case PasswordResetStatus.PASSWORDS_MUST_MATCH:\n        console.log('Passwords must match');\n        break;\n      case PasswordResetStatus.PASSWORD_REUSED:\n        console.log('Cannot reuse previous password');\n        break;\n    }\n  } catch (error) {\n    if (error.message.includes('Code validation failed')) {\n      console.log('Server error during validation');\n    } else {\n      console.log('Network or other error:', error.message);\n    }\n  }\n}\n\nasync function handleInvitationErrors() {\n  try {\n    // For invitation validation\n    const response = await client.validateInvitation({\n      id: 'invitation-uuid-123'\n    });\n    \n    switch (response.status) {\n      case InvitationStatus.VALID:\n        console.log('Invitation is valid, proceed with acceptance');\n        break;\n      case InvitationStatus.EXPIRED:\n        console.log('Invitation has expired, request a new one');\n        break;\n      case InvitationStatus.NOT_FOUND:\n        console.log('Invitation not found');\n        break;\n      case InvitationStatus.ALREADY_ACCEPTED:\n        console.log('Invitation already accepted');\n        break;\n    }\n  } catch (error) {\n    if (error.message.includes('Invitation validation failed')) {\n      console.log('Server error during invitation validation');\n    } else {\n      console.log('Network or other error:', error.message);\n    }\n  }\n}\n```\n\n### Key Features\n\n- **Unauthenticated Access**: All endpoints work without authentication tokens\n- **Auto-Login**: Password change and invitation acceptance can automatically log users in\n- **Comprehensive Validation**: Detailed status codes for all operations\n- **Dedicated Endpoints**: Separate endpoints for password reset codes (`validateCode`) and invitations (`validateInvitation`)\n- **Separate Status Enums**: Password-setting operations use `PasswordResetStatus`, invitation validation uses `InvitationStatus`\n- **Error Handling**: Proper error messages and status codes\n- **Type Safety**: Full TypeScript support with enums and interfaces\n- **Security**: Separate endpoints for different operations with proper validation\n\n\u003e **Note**: Password reset operations (`forgotPassword`, `validateCode`, `changePassword`) and password-setting operations (`acceptInvitation`) use `PasswordResetStatus` enum, while invitation validation (`validateInvitation`) uses `InvitationStatus` enum. For password-setting operations, both `VALID` (0) and `COMPLETED` (4) statuses indicate success.\n\n\u003e **Example**: See the [Password Reset Demo](examples/auth/password-reset.html) for a complete working implementation with UI forms and error handling.\n\n## Environment and Tenant Switching\n\nThe Vysta client supports seamless switching between different environments and tenants while maintaining authentication state. This allows users to work across multiple tenants and environments (dev, staging, production) without needing to re-authenticate.\n\nUsers can switch:\n- **Between environments within the same tenant** (e.g., from Production to Staging within TenantA)\n- **Between different tenants and their environments** (e.g., from TenantA-Production to TenantB-Development)\n- **To any environment they have access to**, regardless of tenant boundaries\n\n### Environment and Tenant Switching Methods\n\n| Method | Description | Parameters | Returns |\n|--------|-------------|------------|---------|\n| `getAvailableEnvironments` | Gets list of available environments for the user | none | `Promise\u003cEnvironmentAvailable[]\u003e` |\n| `switchEnvironment` | Initiates a switch to a different environment | `tenantId: string`\u003cbr\u003e`environmentId: string` | `Promise\u003cstring\u003e` (exchange token) |\n| `constructAuthenticationRedirectUrl` | Builds redirect URL for environment switching | `exchangeToken: string`\u003cbr\u003e`targetHost: string`\u003cbr\u003e`redirectUrl?: string` | `string` |\n| `getCurrentEnvironmentInfo` | Gets current tenant and environment information | none | `{ tenantId: string; envId: string } \\| null` |\n\n### Environment Types\n\n```typescript\ninterface EnvironmentAvailable {\n  environmentId: string;\n  environmentName: string;\n  host: string;\n  tenantId: string;\n  tenantName: string;\n}\n\ninterface CreateEnvironmentResponse {\n  authExchangeToken: string;\n  tenantId: string;\n  envId: string;\n  host: string;\n}\n```\n\n### Environment and Tenant Switching Workflow\n\nThe complete environment and tenant switching process involves these steps:\n\n1. **Get Available Environments** - Fetch all environments across all tenants the user has access to\n2. **Initiate Environment/Tenant Switch** - Get an exchange token for the target tenant and environment\n3. **Exchange Token** - Exchange the token for new authentication credentials in the target tenant/environment\n4. **Update Client Host** - Update the client's host to point to the new environment\n\n```typescript\n// 1. Get available environments across all accessible tenants\nconst environments = await client.getAvailableEnvironments();\nconsole.log('Available environments:', environments);\n\n// Environments are grouped by tenant - you can switch to any combination\n// Example: environments might include:\n// - TenantA: Production, Staging, Development\n// - TenantB: Production, Testing\n// - TenantC: Production\n\n// 2. Switch to a specific tenant and environment\nconst targetEnvironment = environments.find(env =\u003e \n  env.tenantName === 'TenantB' \u0026\u0026 env.environmentName === 'Production'\n);\n\nconst exchangeToken = await client.switchEnvironment(\n  targetEnvironment.tenantId,    // Switch to different tenant\n  targetEnvironment.environmentId // Switch to specific environment within that tenant\n);\n\n// 3. Exchange token for new authentication in target tenant/environment\nconst newAuthResult = await client.exchangeToken(exchangeToken);\n\n// 4. Update client host to target environment\nclient.setHost(targetEnvironment.host);\n\n// 5. Get current tenant and environment info\nconst currentEnv = client.getCurrentEnvironmentInfo();\nconsole.log('Current tenant:', currentEnv.tenantId);\nconsole.log('Current environment:', currentEnv.envId);\n```\n\n### URL-Based Environment and Tenant Switching\n\nFor applications that need to redirect to different hosts (potentially different tenant domains):\n\n```typescript\n// Construct redirect URL for environment/tenant switching\nconst redirectUrl = client.constructAuthenticationRedirectUrl(\n  exchangeToken,\n  targetEnvironment.host,    // Could be a completely different domain for different tenant\n  '/my-app/dashboard'        // Optional: where to redirect after switch\n);\n\n// Redirect to target tenant/environment\nwindow.location.href = redirectUrl;\n```\n\n### Error Handling\n\nEnvironment and tenant switching can fail for several reasons:\n\n```typescript\ntry {\n  const exchangeToken = await client.switchEnvironment(tenantId, environmentId);\n  // ... continue with token exchange\n} catch (error) {\n  if (error.message.includes('Access denied')) {\n    console.log('User does not have access to target tenant/environment');\n  } else if (error.message.includes('Environment switch failed')) {\n    console.log('Environment/tenant switch request failed');\n  } else {\n    console.log('Unexpected error:', error.message);\n  }\n}\n```\n\n### Security Considerations\n\n- **Exchange tokens are short-lived and single-use** - Use them immediately after receiving\n- **Original access tokens are never sent to different tenants/environments** - Each tenant/environment gets its own isolated tokens\n- **All API calls require proper authorization headers** - The client handles this automatically\n- **Token refresh logic handles expiration during tenant/environment switches** - Automatic token management\n- **Tenant isolation is maintained** - Switching between tenants maintains proper security boundaries\n\n\u003e **Example**: See the [Environment Switching Demo](examples/auth/environment-switching.html) for a complete working implementation with multi-tenant environment switching, UI, and debug information.\n\n## License\n\nMIT\n\n### Workflow Services\n\nFor executing workflows, extend the `VystaWorkflowService` base class:\n\n```typescript\nimport { VystaClient, VystaWorkflowService } from '@datavysta/vysta-client';\n\n// Define workflow input types\nexport interface InputTestInput {\n    test: string;\n}\n\n// Create a workflow service\nexport class WorkflowService extends VystaWorkflowService {\n    constructor(client: VystaClient) {\n        super(client);\n    }\n\n    async inputTest(input: InputTestInput): Promise\u003cvoid\u003e {\n        return this.executeWorkflow\u003cInputTestInput, void\u003e('InputTest', input);\n    }\n\n    async plainWait(): Promise\u003cvoid\u003e {\n        return this.executeWorkflow('PlainWait');\n    }\n}\n\n// Use the workflow service\nconst workflows = new WorkflowService(client);\n\n// Execute workflow with input\nawait workflows.inputTest({ test: 'example' });\n\n// Execute workflow without input\nawait workflows.plainWait();\n\n// Execute workflow asynchronously and get a Job ID\nconst jobId = await workflows.inputTestAsync({ test: 'example async' });\nconsole.log('Workflow started asynchronously with Job ID:', jobId);\n```\n\n### Table Audit Service\n\nThe `VystaTableAuditService` provides access to audit history for database table rows, allowing you to track changes, who made them, and when they occurred.\n\n```typescript\nimport { \n  VystaClient, \n  VystaTableAuditService, \n  AuditOperationType,\n  ParsedAuditRecord \n} from '@datavysta/vysta-client';\n\nconst client = new VystaClient({ baseUrl: 'http://localhost:8080' });\nconst auditService = new VystaTableAuditService(client);\n\n// Get raw audit history\nconst auditHistory = await auditService.getTableAudit(\n  'Northwinds',           // connection name\n  'Products',             // table name\n  { productId: 123 },     // primary key fields\n  { limit: 50, offset: 0 } // optional pagination\n);\n\n// Get strongly typed audit history with parsed changedFields\nconst parsedAudit = await auditService.getTableAuditParsed(\n  'Northwinds',\n  'Products', \n  { productId: 123 }\n);\n\n// Process parsed audit records (recommended approach)\nparsedAudit.forEach(record =\u003e {\n  console.log(`${record.username} performed ${getOperationType(record.operationType)} at ${record.timestamp}`);\n  \n  // changedFields is now strongly typed!\n  Object.entries(record.changedFields).forEach(([field, change]) =\u003e {\n    console.log(`  ${field}: ${change.before} → ${change.after}`);\n    if (change.before_display || change.after_display) {\n      console.log(`    Display: ${change.before_display} → ${change.after_display}`);\n    }\n  });\n});\n\nfunction getOperationType(type: number): string {\n  switch (type) {\n    case AuditOperationType.INSERT: return 'INSERT';\n    case AuditOperationType.UPDATE: return 'UPDATE'; \n    case AuditOperationType.DELETE: return 'DELETE';\n    default: return 'UNKNOWN';\n  }\n}\n```\n\n#### Audit Types\n\n```typescript\n// Operation type enum for audit records\nenum AuditOperationType {\n  INSERT = 1,\n  UPDATE = 2,\n  DELETE = 3,\n}\n\n// UUID type alias\ntype UUID = string;\n\n// Single field change in an audit record\ninterface AuditFieldChange {\n  before?: unknown;\n  after?: unknown;\n  before_display?: string;  // Human-readable before value\n  after_display?: string;   // Human-readable after value\n}\n\n// Complete audit record structure\ninterface AuditRecord {\n  id: UUID;\n  name?: string | null;\n  createdOn: string;\n  modifiedOn?: string | null;\n  connectionId: UUID;\n  schemaName?: string | null;\n  tableName: string;\n  operationType: number;    // AuditOperationType enum value (camelCase)\n  rowKey: string;          // JSON string of primary key\n  changedFields: string;   // JSON string of field changes\n  userId?: UUID;\n  username?: string;\n  timestamp: string;       // ISO 8601 timestamp\n  tenantId: string;\n  envId: string;\n}\n\n// Audit record with parsed changedFields (recommended for processing)\ninterface ParsedAuditRecord extends Omit\u003cAuditRecord, 'changedFields'\u003e {\n  changedFields: Record\u003cstring, AuditFieldChange\u003e;  // Strongly typed\n}\n\n// Request structure (primary key fields)\ninterface AuditRequest {\n  [key: string]: any;      // Primary key column names to values\n}\n\n// Response structure\ninterface AuditResponse {\n  recordCount: number;     // Total number of audit records\n  results: AuditRecord[];  // Array of audit records\n}\n```\n\n#### Field Change Format\n\nThe `changedFields` property contains a JSON string with before/after values. Use `getTableAuditParsed()` for strongly typed access:\n\n```typescript\n// Raw changedFields JSON string:\n{\n  \"unitPrice\": {\n    \"before\": \"25.00\",\n    \"after\": \"29.99\",\n    \"before_display\": \"$25.00\",\n    \"after_display\": \"$29.99\"\n  },\n  \"unitsInStock\": {\n    \"before\": \"10\", \n    \"after\": \"15\"\n  }\n}\n\n// Strongly typed access with getTableAuditParsed():\nconst parsedAudit = await auditService.getTableAuditParsed('Northwinds', 'Products', { productId: 123 });\nparsedAudit.forEach(record =\u003e {\n  // record.changedFields is now Record\u003cstring, AuditFieldChange\u003e\n  const priceChange = record.changedFields.unitPrice;\n  if (priceChange) {\n    console.log(`Price: ${priceChange.before} → ${priceChange.after}`);\n    console.log(`Display: ${priceChange.before_display} → ${priceChange.after_display}`);\n  }\n});\n\n// For INSERT operations, only \"after\" values are present\n// For DELETE operations, only \"before\" values are present\n// \"before_display\" and \"after_display\" provide human-readable representations\n```\n\n### Job Service\n\nFor retrieving the status and details of jobs, especially those initiated by asynchronous workflows, use the `VystaAdminJobService`.\n\n```typescript\nimport { VystaClient, VystaAdminJobService, JobStatus, JobSummary, WorkflowService } from '@datavysta/vysta-client';\n\n// Assume client is already initialized and logged in\n// const client = new VystaClient(...);\n// await client.login(...);\n\nconst workflowService = new WorkflowService(client);\nconst jobService = new VystaAdminJobService(client);\n\nasync function monitorJob() {\n  try {\n    // 1. Start an asynchronous workflow\n    const jobId = await workflowService.inputTestAsync({ test: 'monitoring_example' });\n    console.log('Workflow started, Job ID:', jobId);\n\n    // 2. Get the job summary\n    const jobSummary: JobSummary = await jobService.getJobSummary(jobId);\n    console.log('Job Status:', jobSummary.status);\n    console.log('Job Details:', jobSummary);\n\n    // You can poll the job status until it reaches a terminal state\n    if (jobSummary.status === JobStatus.SUCCEEDED) {\n      console.log('Job succeeded!', jobSummary.message);\n    } else if (jobSummary.status === JobStatus.FAILED) {\n      console.error('Job failed:', jobSummary.errormessage);\n    }\n    // Add further polling logic if needed\n\n  } catch (error) {\n    console.error('Error monitoring job:', error);\n  }\n}\n\nmonitorJob();\n```\n\nThe `JobSummary` interface provides detailed information about the job, and `JobStatus` is an enum representing the various states a job can be in.\n\n### Aggregate Queries and Type-Safe Select\n\nYou can use aggregate functions and aliases in your select queries. For type safety, use the exported `SelectColumn\u003cT\u003e` type and `Aggregate` enum:\n\n```typescript\nimport { Aggregate, SelectColumn } from '@datavysta/vysta-client';\n\nconst select: SelectColumn\u003cProduct\u003e[] = [\n  { name: 'unitPrice', aggregate: Aggregate.AVG, alias: 'avgUnitPrice' },\n  { name: 'unitsInStock', aggregate: Aggregate.SUM, alias: 'totalUnitsInStock' },\n  { name: 'productId' },\n];\n\nconst result = await products.query({ select });\nconsole.log(result.data[0].avgUnitPrice, result.data[0].totalUnitsInStock);\n```\n\nYou can also use the string[] format for select, but this is less type-safe:\n\n```typescript\nconst result = await products.query({\n  select: [\n    'AVG(unitPrice)=avgUnitPrice',\n    'SUM(unitsInStock)=totalUnitsInStock',\n    'productId',\n  ]\n});\n```\n\n- `Aggregate` and `SelectColumn` are exported from the package for ergonomic, type-safe aggregate queries.\n- For GET endpoints, you can use string[], object mapping, or SelectColumn[]. For POST endpoints, SelectColumn[] is recommended for type safety.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdatavysta%2Fvysta-client","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdatavysta%2Fvysta-client","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdatavysta%2Fvysta-client/lists"}