mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-02-16 05:16:08 +00:00
feat(api): add ResolveSpec and WebSocket client implementations
- Introduced ResolveSpecClient for REST API interactions. - Added WebSocketClient for real-time communication. - Created types and utility functions for both clients. - Removed deprecated types and example files. - Configured TypeScript and Vite for building the library.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ bin/
|
|||||||
test.db
|
test.db
|
||||||
/testserver
|
/testserver
|
||||||
tests/data/
|
tests/data/
|
||||||
|
node_modules/
|
||||||
11
README.md
11
README.md
@@ -357,6 +357,17 @@ Execute SQL functions and queries through a simple HTTP API with header-based pa
|
|||||||
|
|
||||||
For complete documentation, see [pkg/funcspec/](pkg/funcspec/).
|
For complete documentation, see [pkg/funcspec/](pkg/funcspec/).
|
||||||
|
|
||||||
|
#### ResolveSpec JS - TypeScript Client Library
|
||||||
|
|
||||||
|
TypeScript/JavaScript client library supporting all three REST and WebSocket protocols.
|
||||||
|
|
||||||
|
**Clients**:
|
||||||
|
- Body-based REST client (`read`, `create`, `update`, `deleteEntity`)
|
||||||
|
- Header-based REST client (`HeaderSpecClient`)
|
||||||
|
- WebSocket client (`WebSocketClient`) with CRUD, subscriptions, heartbeat, reconnect
|
||||||
|
|
||||||
|
For complete documentation, see [resolvespec-js/README.md](resolvespec-js/README.md).
|
||||||
|
|
||||||
### Real-Time Communication
|
### Real-Time Communication
|
||||||
|
|
||||||
#### WebSocketSpec - WebSocket API
|
#### WebSocketSpec - WebSocket API
|
||||||
|
|||||||
132
resolvespec-js/PLAN.md
Normal file
132
resolvespec-js/PLAN.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# ResolveSpec JS - Implementation Plan
|
||||||
|
|
||||||
|
TypeScript client library for ResolveSpec, RestHeaderSpec, WebSocket and MQTT APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Phase | Description | Status |
|
||||||
|
|-------|-------------|--------|
|
||||||
|
| 0 | Restructure into folders | Done |
|
||||||
|
| 1 | Fix types (align with Go) | Done |
|
||||||
|
| 2 | Fix REST client | Done |
|
||||||
|
| 3 | Build config | Done |
|
||||||
|
| 4 | Tests | Done |
|
||||||
|
| 5 | HeaderSpec client | Done |
|
||||||
|
| 6 | MQTT client | Planned |
|
||||||
|
| 6.5 | Unified class pattern + singleton factories | Done |
|
||||||
|
| 7 | Response cache (TTL) | Planned |
|
||||||
|
| 8 | TanStack Query integration | Planned |
|
||||||
|
| 9 | React Hooks | Planned |
|
||||||
|
|
||||||
|
**Build:** `dist/index.js` (ES) + `dist/index.cjs` (CJS) + `.d.ts` declarations
|
||||||
|
**Tests:** 65 passing (common: 10, resolvespec: 13, websocketspec: 15, headerspec: 27)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── common/
|
||||||
|
│ ├── types.ts # Core types aligned with Go pkg/common/types.go
|
||||||
|
│ └── index.ts
|
||||||
|
├── resolvespec/
|
||||||
|
│ ├── client.ts # ResolveSpecClient class + createResolveSpecClient singleton
|
||||||
|
│ └── index.ts
|
||||||
|
├── headerspec/
|
||||||
|
│ ├── client.ts # HeaderSpecClient class + createHeaderSpecClient singleton + buildHeaders utility
|
||||||
|
│ └── index.ts
|
||||||
|
├── websocketspec/
|
||||||
|
│ ├── types.ts # WS-specific types (WSMessage, WSOptions, etc.)
|
||||||
|
│ ├── client.ts # WebSocketClient class + createWebSocketClient singleton
|
||||||
|
│ └── index.ts
|
||||||
|
├── mqttspec/ # Future
|
||||||
|
│ ├── types.ts
|
||||||
|
│ ├── client.ts
|
||||||
|
│ └── index.ts
|
||||||
|
├── __tests__/
|
||||||
|
│ ├── common.test.ts
|
||||||
|
│ ├── resolvespec.test.ts
|
||||||
|
│ ├── headerspec.test.ts
|
||||||
|
│ └── websocketspec.test.ts
|
||||||
|
└── index.ts # Root barrel export
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Alignment with Go
|
||||||
|
|
||||||
|
Types in `src/common/types.ts` match `pkg/common/types.go`:
|
||||||
|
|
||||||
|
- **Operator**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, `contains`, `startswith`, `endswith`, `between`, `between_inclusive`, `is_null`, `is_not_null`
|
||||||
|
- **FilterOption**: `column`, `operator`, `value`, `logic_operator` (AND/OR)
|
||||||
|
- **Options**: `columns`, `omit_columns`, `filters`, `sort`, `limit`, `offset`, `preload`, `customOperators`, `computedColumns`, `parameters`, `cursor_forward`, `cursor_backward`, `fetch_row_number`
|
||||||
|
- **PreloadOption**: `relation`, `table_name`, `columns`, `omit_columns`, `sort`, `filters`, `where`, `limit`, `offset`, `updatable`, `recursive`, `computed_ql`, `primary_key`, `related_key`, `foreign_key`, `recursive_child_key`, `sql_joins`, `join_aliases`
|
||||||
|
- **Parameter**: `name`, `value`, `sequence?`
|
||||||
|
- **Metadata**: `total`, `count`, `filtered`, `limit`, `offset`, `row_number?`
|
||||||
|
- **APIError**: `code`, `message`, `details?`, `detail?`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HeaderSpec Header Mapping
|
||||||
|
|
||||||
|
Maps Options to HTTP headers per Go `restheadspec/headers.go`:
|
||||||
|
|
||||||
|
| Header | Options field | Format |
|
||||||
|
|--------|--------------|--------|
|
||||||
|
| `X-Select-Fields` | `columns` | comma-separated |
|
||||||
|
| `X-Not-Select-Fields` | `omit_columns` | comma-separated |
|
||||||
|
| `X-FieldFilter-{col}` | `filters` (eq, AND) | value |
|
||||||
|
| `X-SearchOp-{op}-{col}` | `filters` (AND) | value |
|
||||||
|
| `X-SearchOr-{op}-{col}` | `filters` (OR) | value |
|
||||||
|
| `X-Sort` | `sort` | `+col` (asc), `-col` (desc) |
|
||||||
|
| `X-Limit` | `limit` | number |
|
||||||
|
| `X-Offset` | `offset` | number |
|
||||||
|
| `X-Cursor-Forward` | `cursor_forward` | string |
|
||||||
|
| `X-Cursor-Backward` | `cursor_backward` | string |
|
||||||
|
| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated |
|
||||||
|
| `X-Fetch-RowNumber` | `fetch_row_number` | string |
|
||||||
|
| `X-CQL-SEL-{col}` | `computedColumns` | expression |
|
||||||
|
| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined |
|
||||||
|
|
||||||
|
Complex values use `ZIP_` + base64 encoding.
|
||||||
|
HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm run build # vite library mode → dist/
|
||||||
|
pnpm run test # vitest
|
||||||
|
pnpm run lint # eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config files:** `tsconfig.json` (ES2020, strict, bundler), `vite.config.ts` (lib mode, dts via vite-plugin-dts)
|
||||||
|
**Externals:** `uuid`, `semver`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
- **Phase 6 — MQTT Client**: Topic-based CRUD over MQTT (optional/future)
|
||||||
|
- **Phase 7 — Cache**: In-memory response cache with TTL, key = URL + options hash, auto-invalidation on CUD, `skipCache` flag
|
||||||
|
- **Phase 8 — TanStack Query Integration**: Query/mutation hooks wrapping each client, query key factories, automatic cache invalidation
|
||||||
|
- **Phase 9 — React Hooks**: `useResolveSpec`, `useHeaderSpec`, `useWebSocket` hooks with provider context, loading/error states
|
||||||
|
- ESLint config may need updating for new folder structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
| Purpose | Path |
|
||||||
|
|---------|------|
|
||||||
|
| Go types (source of truth) | `pkg/common/types.go` |
|
||||||
|
| Go REST handler | `pkg/resolvespec/handler.go` |
|
||||||
|
| Go HeaderSpec handler | `pkg/restheadspec/handler.go` |
|
||||||
|
| Go HeaderSpec header parsing | `pkg/restheadspec/headers.go` |
|
||||||
|
| Go test models | `pkg/testmodels/business.go` |
|
||||||
|
| Go tests | `tests/crud_test.go` |
|
||||||
213
resolvespec-js/README.md
Normal file
213
resolvespec-js/README.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# ResolveSpec JS
|
||||||
|
|
||||||
|
TypeScript client library for ResolveSpec APIs. Supports body-based REST, header-based REST, and WebSocket protocols.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @warkypublic/resolvespec-js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clients
|
||||||
|
|
||||||
|
| Client | Protocol | Singleton Factory |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ResolveSpecClient` | REST (body-based) | `getResolveSpecClient(config)` |
|
||||||
|
| `HeaderSpecClient` | REST (header-based) | `getHeaderSpecClient(config)` |
|
||||||
|
| `WebSocketClient` | WebSocket | `getWebSocketClient(config)` |
|
||||||
|
|
||||||
|
All clients use the class pattern. Singleton factories return cached instances keyed by URL.
|
||||||
|
|
||||||
|
## REST Client (Body-Based)
|
||||||
|
|
||||||
|
Options sent in JSON request body. Maps to Go `pkg/resolvespec`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ResolveSpecClient, getResolveSpecClient } from '@warkypublic/resolvespec-js';
|
||||||
|
|
||||||
|
// Class instantiation
|
||||||
|
const client = new ResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
|
||||||
|
|
||||||
|
// Or singleton factory (returns cached instance per baseUrl)
|
||||||
|
const client = getResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
|
||||||
|
|
||||||
|
// Read with filters, sort, pagination
|
||||||
|
const result = await client.read('public', 'users', undefined, {
|
||||||
|
columns: ['id', 'name', 'email'],
|
||||||
|
filters: [{ column: 'status', operator: 'eq', value: 'active' }],
|
||||||
|
sort: [{ column: 'name', direction: 'asc' }],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
preload: [{ relation: 'Posts', columns: ['id', 'title'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read by ID
|
||||||
|
const user = await client.read('public', 'users', 42);
|
||||||
|
|
||||||
|
// Create
|
||||||
|
const created = await client.create('public', 'users', { name: 'New User' });
|
||||||
|
|
||||||
|
// Update
|
||||||
|
await client.update('public', 'users', { name: 'Updated' }, 42);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
await client.delete('public', 'users', 42);
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
const meta = await client.getMetadata('public', 'users');
|
||||||
|
```
|
||||||
|
|
||||||
|
## HeaderSpec Client (Header-Based)
|
||||||
|
|
||||||
|
Options sent via HTTP headers. Maps to Go `pkg/restheadspec`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { HeaderSpecClient, getHeaderSpecClient } from '@warkypublic/resolvespec-js';
|
||||||
|
|
||||||
|
const client = new HeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
|
||||||
|
// Or: const client = getHeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' });
|
||||||
|
|
||||||
|
// GET with options as headers
|
||||||
|
const result = await client.read('public', 'users', undefined, {
|
||||||
|
columns: ['id', 'name'],
|
||||||
|
filters: [
|
||||||
|
{ column: 'status', operator: 'eq', value: 'active' },
|
||||||
|
{ column: 'age', operator: 'gte', value: 18, logic_operator: 'AND' },
|
||||||
|
],
|
||||||
|
sort: [{ column: 'name', direction: 'asc' }],
|
||||||
|
limit: 50,
|
||||||
|
preload: [{ relation: 'Department', columns: ['id', 'name'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST create
|
||||||
|
await client.create('public', 'users', { name: 'New User' });
|
||||||
|
|
||||||
|
// PUT update
|
||||||
|
await client.update('public', 'users', '42', { name: 'Updated' });
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
await client.delete('public', 'users', '42');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header Mapping
|
||||||
|
|
||||||
|
| Header | Options Field | Format |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `X-Select-Fields` | `columns` | comma-separated |
|
||||||
|
| `X-Not-Select-Fields` | `omit_columns` | comma-separated |
|
||||||
|
| `X-FieldFilter-{col}` | `filters` (eq, AND) | value |
|
||||||
|
| `X-SearchOp-{op}-{col}` | `filters` (AND) | value |
|
||||||
|
| `X-SearchOr-{op}-{col}` | `filters` (OR) | value |
|
||||||
|
| `X-Sort` | `sort` | `+col` asc, `-col` desc |
|
||||||
|
| `X-Limit` / `X-Offset` | `limit` / `offset` | number |
|
||||||
|
| `X-Cursor-Forward` | `cursor_forward` | string |
|
||||||
|
| `X-Cursor-Backward` | `cursor_backward` | string |
|
||||||
|
| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated |
|
||||||
|
| `X-Fetch-RowNumber` | `fetch_row_number` | string |
|
||||||
|
| `X-CQL-SEL-{col}` | `computedColumns` | expression |
|
||||||
|
| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined |
|
||||||
|
|
||||||
|
### Utility Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { buildHeaders, encodeHeaderValue, decodeHeaderValue } from '@warkypublic/resolvespec-js';
|
||||||
|
|
||||||
|
const headers = buildHeaders({ columns: ['id', 'name'], limit: 10 });
|
||||||
|
// => { 'X-Select-Fields': 'id,name', 'X-Limit': '10' }
|
||||||
|
|
||||||
|
const encoded = encodeHeaderValue('complex value'); // 'ZIP_...'
|
||||||
|
const decoded = decodeHeaderValue(encoded); // 'complex value'
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket Client
|
||||||
|
|
||||||
|
Real-time CRUD with subscriptions. Maps to Go `pkg/websocketspec`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { WebSocketClient, getWebSocketClient } from '@warkypublic/resolvespec-js';
|
||||||
|
|
||||||
|
const ws = new WebSocketClient({
|
||||||
|
url: 'ws://localhost:8080/ws',
|
||||||
|
reconnect: true,
|
||||||
|
heartbeatInterval: 30000,
|
||||||
|
});
|
||||||
|
// Or: const ws = getWebSocketClient({ url: 'ws://localhost:8080/ws' });
|
||||||
|
|
||||||
|
await ws.connect();
|
||||||
|
|
||||||
|
// CRUD
|
||||||
|
const users = await ws.read('users', { schema: 'public', limit: 10 });
|
||||||
|
const created = await ws.create('users', { name: 'New' }, { schema: 'public' });
|
||||||
|
await ws.update('users', '1', { name: 'Updated' });
|
||||||
|
await ws.delete('users', '1');
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
const subId = await ws.subscribe('users', (notification) => {
|
||||||
|
console.log(notification.operation, notification.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe
|
||||||
|
await ws.unsubscribe(subId);
|
||||||
|
|
||||||
|
// Events
|
||||||
|
ws.on('connect', () => console.log('connected'));
|
||||||
|
ws.on('disconnect', () => console.log('disconnected'));
|
||||||
|
ws.on('error', (err) => console.error(err));
|
||||||
|
|
||||||
|
ws.disconnect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
All types align with Go `pkg/common/types.go`.
|
||||||
|
|
||||||
|
### Key Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Options {
|
||||||
|
columns?: string[];
|
||||||
|
omit_columns?: string[];
|
||||||
|
filters?: FilterOption[];
|
||||||
|
sort?: SortOption[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
preload?: PreloadOption[];
|
||||||
|
customOperators?: CustomOperator[];
|
||||||
|
computedColumns?: ComputedColumn[];
|
||||||
|
parameters?: Parameter[];
|
||||||
|
cursor_forward?: string;
|
||||||
|
cursor_backward?: string;
|
||||||
|
fetch_row_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
column: string;
|
||||||
|
operator: Operator | string;
|
||||||
|
value: any;
|
||||||
|
logic_operator?: 'AND' | 'OR';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators: eq, neq, gt, gte, lt, lte, like, ilike, in,
|
||||||
|
// contains, startswith, endswith, between,
|
||||||
|
// between_inclusive, is_null, is_not_null
|
||||||
|
|
||||||
|
interface APIResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
metadata?: Metadata;
|
||||||
|
error?: APIError;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm run build # dist/index.js (ES) + dist/index.cjs (CJS) + .d.ts
|
||||||
|
pnpm run test # vitest
|
||||||
|
pnpm run lint # eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
# WebSocketSpec JavaScript Client
|
|
||||||
|
|
||||||
A TypeScript/JavaScript client for connecting to WebSocketSpec servers with full support for real-time subscriptions, CRUD operations, and automatic reconnection.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @warkypublic/resolvespec-js
|
|
||||||
# or
|
|
||||||
yarn add @warkypublic/resolvespec-js
|
|
||||||
# or
|
|
||||||
pnpm add @warkypublic/resolvespec-js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { WebSocketClient } from '@warkypublic/resolvespec-js';
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws',
|
|
||||||
reconnect: true,
|
|
||||||
debug: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Read records
|
|
||||||
const users = await client.read('users', {
|
|
||||||
schema: 'public',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'active' }
|
|
||||||
],
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to changes
|
|
||||||
const subscriptionId = await client.subscribe('users', (notification) => {
|
|
||||||
console.log('User changed:', notification.operation, notification.data);
|
|
||||||
}, { schema: 'public' });
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
await client.unsubscribe(subscriptionId);
|
|
||||||
client.disconnect();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Real-Time Updates**: Subscribe to entity changes and receive instant notifications
|
|
||||||
- **Full CRUD Support**: Create, read, update, and delete operations
|
|
||||||
- **TypeScript Support**: Full type definitions included
|
|
||||||
- **Auto Reconnection**: Automatic reconnection with configurable retry logic
|
|
||||||
- **Heartbeat**: Built-in keepalive mechanism
|
|
||||||
- **Event System**: Listen to connection, error, and message events
|
|
||||||
- **Promise-based API**: All async operations return promises
|
|
||||||
- **Filter & Sort**: Advanced querying with filters, sorting, and pagination
|
|
||||||
- **Preloading**: Load related entities in a single query
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws', // WebSocket server URL
|
|
||||||
reconnect: true, // Enable auto-reconnection
|
|
||||||
reconnectInterval: 3000, // Reconnection delay (ms)
|
|
||||||
maxReconnectAttempts: 10, // Max reconnection attempts
|
|
||||||
heartbeatInterval: 30000, // Heartbeat interval (ms)
|
|
||||||
debug: false // Enable debug logging
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Connection Management
|
|
||||||
|
|
||||||
#### `connect(): Promise<void>`
|
|
||||||
Connect to the WebSocket server.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.connect();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `disconnect(): void`
|
|
||||||
Disconnect from the server.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
client.disconnect();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `isConnected(): boolean`
|
|
||||||
Check if currently connected.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (client.isConnected()) {
|
|
||||||
console.log('Connected!');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getState(): ConnectionState`
|
|
||||||
Get current connection state: `'connecting'`, `'connected'`, `'disconnecting'`, `'disconnected'`, or `'reconnecting'`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const state = client.getState();
|
|
||||||
console.log('State:', state);
|
|
||||||
```
|
|
||||||
|
|
||||||
### CRUD Operations
|
|
||||||
|
|
||||||
#### `read<T>(entity: string, options?): Promise<T>`
|
|
||||||
Read records from an entity.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Read all active users
|
|
||||||
const users = await client.read('users', {
|
|
||||||
schema: 'public',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'active' }
|
|
||||||
],
|
|
||||||
columns: ['id', 'name', 'email'],
|
|
||||||
sort: [
|
|
||||||
{ column: 'name', direction: 'asc' }
|
|
||||||
],
|
|
||||||
limit: 10,
|
|
||||||
offset: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read single record by ID
|
|
||||||
const user = await client.read('users', {
|
|
||||||
schema: 'public',
|
|
||||||
record_id: '123'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read with preloading
|
|
||||||
const posts = await client.read('posts', {
|
|
||||||
schema: 'public',
|
|
||||||
preload: [
|
|
||||||
{
|
|
||||||
relation: 'user',
|
|
||||||
columns: ['id', 'name', 'email']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
relation: 'comments',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'approved' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `create<T>(entity: string, data: any, options?): Promise<T>`
|
|
||||||
Create a new record.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const newUser = await client.create('users', {
|
|
||||||
name: 'John Doe',
|
|
||||||
email: 'john@example.com',
|
|
||||||
status: 'active'
|
|
||||||
}, {
|
|
||||||
schema: 'public'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `update<T>(entity: string, id: string, data: any, options?): Promise<T>`
|
|
||||||
Update an existing record.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const updatedUser = await client.update('users', '123', {
|
|
||||||
name: 'John Updated',
|
|
||||||
email: 'john.new@example.com'
|
|
||||||
}, {
|
|
||||||
schema: 'public'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `delete(entity: string, id: string, options?): Promise<void>`
|
|
||||||
Delete a record.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.delete('users', '123', {
|
|
||||||
schema: 'public'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `meta<T>(entity: string, options?): Promise<T>`
|
|
||||||
Get metadata for an entity.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const metadata = await client.meta('users', {
|
|
||||||
schema: 'public'
|
|
||||||
});
|
|
||||||
console.log('Columns:', metadata.columns);
|
|
||||||
console.log('Primary key:', metadata.primary_key);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Subscriptions
|
|
||||||
|
|
||||||
#### `subscribe(entity: string, callback: Function, options?): Promise<string>`
|
|
||||||
Subscribe to entity changes.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const subscriptionId = await client.subscribe(
|
|
||||||
'users',
|
|
||||||
(notification) => {
|
|
||||||
console.log('Operation:', notification.operation); // 'create', 'update', or 'delete'
|
|
||||||
console.log('Data:', notification.data);
|
|
||||||
console.log('Timestamp:', notification.timestamp);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
schema: 'public',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'active' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `unsubscribe(subscriptionId: string): Promise<void>`
|
|
||||||
Unsubscribe from entity changes.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.unsubscribe(subscriptionId);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getSubscriptions(): Subscription[]`
|
|
||||||
Get list of active subscriptions.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const subscriptions = client.getSubscriptions();
|
|
||||||
console.log('Active subscriptions:', subscriptions.length);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Handling
|
|
||||||
|
|
||||||
#### `on(event: string, callback: Function): void`
|
|
||||||
Add event listener.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Connection events
|
|
||||||
client.on('connect', () => {
|
|
||||||
console.log('Connected!');
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('disconnect', (event) => {
|
|
||||||
console.log('Disconnected:', event.code, event.reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// State changes
|
|
||||||
client.on('stateChange', (state) => {
|
|
||||||
console.log('State:', state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// All messages
|
|
||||||
client.on('message', (message) => {
|
|
||||||
console.log('Message:', message);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `off(event: string): void`
|
|
||||||
Remove event listener.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
client.off('connect');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Filter Operators
|
|
||||||
|
|
||||||
- `eq` - Equal (=)
|
|
||||||
- `neq` - Not Equal (!=)
|
|
||||||
- `gt` - Greater Than (>)
|
|
||||||
- `gte` - Greater Than or Equal (>=)
|
|
||||||
- `lt` - Less Than (<)
|
|
||||||
- `lte` - Less Than or Equal (<=)
|
|
||||||
- `like` - LIKE (case-sensitive)
|
|
||||||
- `ilike` - ILIKE (case-insensitive)
|
|
||||||
- `in` - IN (array of values)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Basic CRUD
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Create
|
|
||||||
const user = await client.create('users', {
|
|
||||||
name: 'Alice',
|
|
||||||
email: 'alice@example.com'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read
|
|
||||||
const users = await client.read('users', {
|
|
||||||
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update
|
|
||||||
await client.update('users', user.id, { name: 'Alice Updated' });
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
await client.delete('users', user.id);
|
|
||||||
|
|
||||||
client.disconnect();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Real-Time Subscriptions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Subscribe to all user changes
|
|
||||||
const subId = await client.subscribe('users', (notification) => {
|
|
||||||
switch (notification.operation) {
|
|
||||||
case 'create':
|
|
||||||
console.log('New user:', notification.data);
|
|
||||||
break;
|
|
||||||
case 'update':
|
|
||||||
console.log('User updated:', notification.data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
console.log('User deleted:', notification.data);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Later: unsubscribe
|
|
||||||
await client.unsubscribe(subId);
|
|
||||||
```
|
|
||||||
|
|
||||||
### React Integration
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { WebSocketClient } from '@warkypublic/resolvespec-js';
|
|
||||||
|
|
||||||
function useWebSocket(url: string) {
|
|
||||||
const [client] = useState(() => new WebSocketClient({ url }));
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
client.on('connect', () => setIsConnected(true));
|
|
||||||
client.on('disconnect', () => setIsConnected(false));
|
|
||||||
client.connect();
|
|
||||||
|
|
||||||
return () => client.disconnect();
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
return { client, isConnected };
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsersComponent() {
|
|
||||||
const { client, isConnected } = useWebSocket('ws://localhost:8080/ws');
|
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isConnected) return;
|
|
||||||
|
|
||||||
const loadUsers = async () => {
|
|
||||||
// Subscribe to changes
|
|
||||||
await client.subscribe('users', (notification) => {
|
|
||||||
if (notification.operation === 'create') {
|
|
||||||
setUsers(prev => [...prev, notification.data]);
|
|
||||||
} else if (notification.operation === 'update') {
|
|
||||||
setUsers(prev => prev.map(u =>
|
|
||||||
u.id === notification.data.id ? notification.data : u
|
|
||||||
));
|
|
||||||
} else if (notification.operation === 'delete') {
|
|
||||||
setUsers(prev => prev.filter(u => u.id !== notification.data.id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load initial data
|
|
||||||
const data = await client.read('users');
|
|
||||||
setUsers(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadUsers();
|
|
||||||
}, [client, isConnected]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Users {isConnected ? '🟢' : '🔴'}</h2>
|
|
||||||
{users.map(user => (
|
|
||||||
<div key={user.id}>{user.name}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### TypeScript with Typed Models
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface User {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
status: 'active' | 'inactive';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Post {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
user_id: number;
|
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Type-safe operations
|
|
||||||
const users = await client.read<User[]>('users', {
|
|
||||||
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
const newUser = await client.create<User>('users', {
|
|
||||||
name: 'Bob',
|
|
||||||
email: 'bob@example.com',
|
|
||||||
status: 'active'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Type-safe subscriptions
|
|
||||||
await client.subscribe(
|
|
||||||
'posts',
|
|
||||||
(notification) => {
|
|
||||||
const post = notification.data as Post;
|
|
||||||
console.log('Post:', post.title);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws',
|
|
||||||
reconnect: true,
|
|
||||||
maxReconnectAttempts: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('Connection error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('stateChange', (state) => {
|
|
||||||
console.log('State:', state);
|
|
||||||
if (state === 'reconnecting') {
|
|
||||||
console.log('Attempting to reconnect...');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await client.read('users', { record_id: '999' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Record not found:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.create('users', { /* invalid data */ });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Validation failed:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Connection failed:', error);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Subscriptions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const client = new WebSocketClient({ url: 'ws://localhost:8080/ws' });
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Subscribe to multiple entities
|
|
||||||
const userSub = await client.subscribe('users', (n) => {
|
|
||||||
console.log('[Users]', n.operation, n.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
const postSub = await client.subscribe('posts', (n) => {
|
|
||||||
console.log('[Posts]', n.operation, n.data);
|
|
||||||
}, {
|
|
||||||
filters: [{ column: 'status', operator: 'eq', value: 'published' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
const commentSub = await client.subscribe('comments', (n) => {
|
|
||||||
console.log('[Comments]', n.operation, n.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check active subscriptions
|
|
||||||
console.log('Active:', client.getSubscriptions().length);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
await client.unsubscribe(userSub);
|
|
||||||
await client.unsubscribe(postSub);
|
|
||||||
await client.unsubscribe(commentSub);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Always Clean Up**: Call `disconnect()` when done to close the connection properly
|
|
||||||
2. **Use TypeScript**: Leverage type definitions for better type safety
|
|
||||||
3. **Handle Errors**: Always wrap operations in try-catch blocks
|
|
||||||
4. **Limit Subscriptions**: Don't create too many subscriptions per connection
|
|
||||||
5. **Use Filters**: Apply filters to subscriptions to reduce unnecessary notifications
|
|
||||||
6. **Connection State**: Check `isConnected()` before operations
|
|
||||||
7. **Event Listeners**: Remove event listeners when no longer needed with `off()`
|
|
||||||
8. **Reconnection**: Enable auto-reconnection for production apps
|
|
||||||
|
|
||||||
## Browser Support
|
|
||||||
|
|
||||||
- Chrome/Edge 88+
|
|
||||||
- Firefox 85+
|
|
||||||
- Safari 14+
|
|
||||||
- Node.js 14.16+
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
1
resolvespec-js/dist/index.cjs
vendored
Normal file
1
resolvespec-js/dist/index.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
366
resolvespec-js/dist/index.d.ts
vendored
Normal file
366
resolvespec-js/dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
export declare interface APIError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface APIResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
metadata?: Metadata;
|
||||||
|
error?: APIError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTTP headers from Options, matching Go's restheadspec handler conventions.
|
||||||
|
*
|
||||||
|
* Header mapping:
|
||||||
|
* - X-Select-Fields: comma-separated columns
|
||||||
|
* - X-Not-Select-Fields: comma-separated omit_columns
|
||||||
|
* - X-FieldFilter-{col}: exact match (eq)
|
||||||
|
* - X-SearchOp-{operator}-{col}: AND filter
|
||||||
|
* - X-SearchOr-{operator}-{col}: OR filter
|
||||||
|
* - X-Sort: +col (asc), -col (desc)
|
||||||
|
* - X-Limit, X-Offset: pagination
|
||||||
|
* - X-Cursor-Forward, X-Cursor-Backward: cursor pagination
|
||||||
|
* - X-Preload: RelationName:field1,field2 pipe-separated
|
||||||
|
* - X-Fetch-RowNumber: row number fetch
|
||||||
|
* - X-CQL-SEL-{col}: computed columns
|
||||||
|
* - X-Custom-SQL-W: custom operators (AND)
|
||||||
|
*/
|
||||||
|
export declare function buildHeaders(options: Options): Record<string, string>;
|
||||||
|
|
||||||
|
export declare interface ClientConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface Column {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
is_nullable: boolean;
|
||||||
|
is_primary: boolean;
|
||||||
|
is_unique: boolean;
|
||||||
|
has_index: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface ComputedColumn {
|
||||||
|
name: string;
|
||||||
|
expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'reconnecting';
|
||||||
|
|
||||||
|
export declare interface CustomOperator {
|
||||||
|
name: string;
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a header value that may be base64 encoded with ZIP_ or __ prefix.
|
||||||
|
*/
|
||||||
|
export declare function decodeHeaderValue(value: string): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a value with base64 and ZIP_ prefix for complex header values.
|
||||||
|
*/
|
||||||
|
export declare function encodeHeaderValue(value: string): string;
|
||||||
|
|
||||||
|
export declare interface FilterOption {
|
||||||
|
column: string;
|
||||||
|
operator: Operator | string;
|
||||||
|
value: any;
|
||||||
|
logic_operator?: 'AND' | 'OR';
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient;
|
||||||
|
|
||||||
|
export declare function getResolveSpecClient(config: ClientConfig): ResolveSpecClient;
|
||||||
|
|
||||||
|
export declare function getWebSocketClient(config: WebSocketClientConfig): WebSocketClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeaderSpec REST client.
|
||||||
|
* Sends query options via HTTP headers instead of request body, matching the Go restheadspec handler.
|
||||||
|
*
|
||||||
|
* HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete
|
||||||
|
*/
|
||||||
|
export declare class HeaderSpecClient {
|
||||||
|
private config;
|
||||||
|
constructor(config: ClientConfig);
|
||||||
|
private buildUrl;
|
||||||
|
private baseHeaders;
|
||||||
|
private fetchWithError;
|
||||||
|
read<T = any>(schema: string, entity: string, id?: string, options?: Options): Promise<APIResponse<T>>;
|
||||||
|
create<T = any>(schema: string, entity: string, data: any, options?: Options): Promise<APIResponse<T>>;
|
||||||
|
update<T = any>(schema: string, entity: string, id: string, data: any, options?: Options): Promise<APIResponse<T>>;
|
||||||
|
delete(schema: string, entity: string, id: string): Promise<APIResponse<void>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
|
||||||
|
|
||||||
|
export declare interface Metadata {
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
filtered: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
row_number?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare type Operation = 'read' | 'create' | 'update' | 'delete';
|
||||||
|
|
||||||
|
export declare type Operator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'in' | 'contains' | 'startswith' | 'endswith' | 'between' | 'between_inclusive' | 'is_null' | 'is_not_null';
|
||||||
|
|
||||||
|
export declare interface Options {
|
||||||
|
preload?: PreloadOption[];
|
||||||
|
columns?: string[];
|
||||||
|
omit_columns?: string[];
|
||||||
|
filters?: FilterOption[];
|
||||||
|
sort?: SortOption[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
customOperators?: CustomOperator[];
|
||||||
|
computedColumns?: ComputedColumn[];
|
||||||
|
parameters?: Parameter[];
|
||||||
|
cursor_forward?: string;
|
||||||
|
cursor_backward?: string;
|
||||||
|
fetch_row_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface Parameter {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
sequence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface PreloadOption {
|
||||||
|
relation: string;
|
||||||
|
table_name?: string;
|
||||||
|
columns?: string[];
|
||||||
|
omit_columns?: string[];
|
||||||
|
sort?: SortOption[];
|
||||||
|
filters?: FilterOption[];
|
||||||
|
where?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
updatable?: boolean;
|
||||||
|
computed_ql?: Record<string, string>;
|
||||||
|
recursive?: boolean;
|
||||||
|
primary_key?: string;
|
||||||
|
related_key?: string;
|
||||||
|
foreign_key?: string;
|
||||||
|
recursive_child_key?: string;
|
||||||
|
sql_joins?: string[];
|
||||||
|
join_aliases?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface RequestBody {
|
||||||
|
operation: Operation;
|
||||||
|
id?: number | string | string[];
|
||||||
|
data?: any | any[];
|
||||||
|
options?: Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare class ResolveSpecClient {
|
||||||
|
private config;
|
||||||
|
constructor(config: ClientConfig);
|
||||||
|
private buildUrl;
|
||||||
|
private baseHeaders;
|
||||||
|
private fetchWithError;
|
||||||
|
getMetadata(schema: string, entity: string): Promise<APIResponse<TableMetadata>>;
|
||||||
|
read<T = any>(schema: string, entity: string, id?: number | string | string[], options?: Options): Promise<APIResponse<T>>;
|
||||||
|
create<T = any>(schema: string, entity: string, data: any | any[], options?: Options): Promise<APIResponse<T>>;
|
||||||
|
update<T = any>(schema: string, entity: string, data: any | any[], id?: number | string | string[], options?: Options): Promise<APIResponse<T>>;
|
||||||
|
delete(schema: string, entity: string, id: number | string): Promise<APIResponse<void>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare type SortDirection = 'asc' | 'desc' | 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
export declare interface SortOption {
|
||||||
|
column: string;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface Subscription {
|
||||||
|
id: string;
|
||||||
|
entity: string;
|
||||||
|
schema?: string;
|
||||||
|
options?: WSOptions;
|
||||||
|
callback?: (notification: WSNotificationMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface SubscriptionOptions {
|
||||||
|
filters?: FilterOption[];
|
||||||
|
onNotification?: (notification: WSNotificationMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface TableMetadata {
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
columns: Column[];
|
||||||
|
relations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare class WebSocketClient {
|
||||||
|
private ws;
|
||||||
|
private config;
|
||||||
|
private messageHandlers;
|
||||||
|
private subscriptions;
|
||||||
|
private eventListeners;
|
||||||
|
private state;
|
||||||
|
private reconnectAttempts;
|
||||||
|
private reconnectTimer;
|
||||||
|
private heartbeatTimer;
|
||||||
|
private isManualClose;
|
||||||
|
constructor(config: WebSocketClientConfig);
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): void;
|
||||||
|
request<T = any>(operation: WSOperation, entity: string, options?: {
|
||||||
|
schema?: string;
|
||||||
|
record_id?: string;
|
||||||
|
data?: any;
|
||||||
|
options?: WSOptions;
|
||||||
|
}): Promise<T>;
|
||||||
|
read<T = any>(entity: string, options?: {
|
||||||
|
schema?: string;
|
||||||
|
record_id?: string;
|
||||||
|
filters?: FilterOption[];
|
||||||
|
columns?: string[];
|
||||||
|
sort?: SortOption[];
|
||||||
|
preload?: PreloadOption[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<T>;
|
||||||
|
create<T = any>(entity: string, data: any, options?: {
|
||||||
|
schema?: string;
|
||||||
|
}): Promise<T>;
|
||||||
|
update<T = any>(entity: string, id: string, data: any, options?: {
|
||||||
|
schema?: string;
|
||||||
|
}): Promise<T>;
|
||||||
|
delete(entity: string, id: string, options?: {
|
||||||
|
schema?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
meta<T = any>(entity: string, options?: {
|
||||||
|
schema?: string;
|
||||||
|
}): Promise<T>;
|
||||||
|
subscribe(entity: string, callback: (notification: WSNotificationMessage) => void, options?: {
|
||||||
|
schema?: string;
|
||||||
|
filters?: FilterOption[];
|
||||||
|
}): Promise<string>;
|
||||||
|
unsubscribe(subscriptionId: string): Promise<void>;
|
||||||
|
getSubscriptions(): Subscription[];
|
||||||
|
getState(): ConnectionState;
|
||||||
|
isConnected(): boolean;
|
||||||
|
on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void;
|
||||||
|
off<K extends keyof WebSocketClientEvents>(event: K): void;
|
||||||
|
private handleMessage;
|
||||||
|
private handleResponse;
|
||||||
|
private handleNotification;
|
||||||
|
private send;
|
||||||
|
private startHeartbeat;
|
||||||
|
private stopHeartbeat;
|
||||||
|
private setState;
|
||||||
|
private ensureConnected;
|
||||||
|
private emit;
|
||||||
|
private log;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WebSocketClientConfig {
|
||||||
|
url: string;
|
||||||
|
reconnect?: boolean;
|
||||||
|
reconnectInterval?: number;
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
|
heartbeatInterval?: number;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WebSocketClientEvents {
|
||||||
|
connect: () => void;
|
||||||
|
disconnect: (event: CloseEvent) => void;
|
||||||
|
error: (error: Error) => void;
|
||||||
|
message: (message: WSMessage) => void;
|
||||||
|
stateChange: (state: ConnectionState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WSErrorInfo {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WSMessage {
|
||||||
|
id?: string;
|
||||||
|
type: MessageType;
|
||||||
|
operation?: WSOperation;
|
||||||
|
schema?: string;
|
||||||
|
entity?: string;
|
||||||
|
record_id?: string;
|
||||||
|
data?: any;
|
||||||
|
options?: WSOptions;
|
||||||
|
subscription_id?: string;
|
||||||
|
success?: boolean;
|
||||||
|
error?: WSErrorInfo;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WSNotificationMessage {
|
||||||
|
type: 'notification';
|
||||||
|
operation: WSOperation;
|
||||||
|
subscription_id: string;
|
||||||
|
schema?: string;
|
||||||
|
entity: string;
|
||||||
|
data: any;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta';
|
||||||
|
|
||||||
|
export declare interface WSOptions {
|
||||||
|
filters?: FilterOption[];
|
||||||
|
columns?: string[];
|
||||||
|
omit_columns?: string[];
|
||||||
|
preload?: PreloadOption[];
|
||||||
|
sort?: SortOption[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
parameters?: Parameter[];
|
||||||
|
cursor_forward?: string;
|
||||||
|
cursor_backward?: string;
|
||||||
|
fetch_row_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WSRequestMessage {
|
||||||
|
id: string;
|
||||||
|
type: 'request';
|
||||||
|
operation: WSOperation;
|
||||||
|
schema?: string;
|
||||||
|
entity: string;
|
||||||
|
record_id?: string;
|
||||||
|
data?: any;
|
||||||
|
options?: WSOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WSResponseMessage {
|
||||||
|
id: string;
|
||||||
|
type: 'response';
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
error?: WSErrorInfo;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare interface WSSubscriptionMessage {
|
||||||
|
id: string;
|
||||||
|
type: 'subscription';
|
||||||
|
operation: 'subscribe' | 'unsubscribe';
|
||||||
|
schema?: string;
|
||||||
|
entity: string;
|
||||||
|
options?: WSOptions;
|
||||||
|
subscription_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { }
|
||||||
452
resolvespec-js/dist/index.js
vendored
Normal file
452
resolvespec-js/dist/index.js
vendored
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import { v4 as l } from "uuid";
|
||||||
|
const d = /* @__PURE__ */ new Map();
|
||||||
|
function E(n) {
|
||||||
|
const e = n.baseUrl;
|
||||||
|
let t = d.get(e);
|
||||||
|
return t || (t = new g(n), d.set(e, t)), t;
|
||||||
|
}
|
||||||
|
class g {
|
||||||
|
constructor(e) {
|
||||||
|
this.config = e;
|
||||||
|
}
|
||||||
|
buildUrl(e, t, s) {
|
||||||
|
let r = `${this.config.baseUrl}/${e}/${t}`;
|
||||||
|
return s && (r += `/${s}`), r;
|
||||||
|
}
|
||||||
|
baseHeaders() {
|
||||||
|
const e = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
};
|
||||||
|
return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e;
|
||||||
|
}
|
||||||
|
async fetchWithError(e, t) {
|
||||||
|
const s = await fetch(e, t), r = await s.json();
|
||||||
|
if (!s.ok)
|
||||||
|
throw new Error(r.error?.message || "An error occurred");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
async getMetadata(e, t) {
|
||||||
|
const s = this.buildUrl(e, t);
|
||||||
|
return this.fetchWithError(s, {
|
||||||
|
method: "GET",
|
||||||
|
headers: this.baseHeaders()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async read(e, t, s, r) {
|
||||||
|
const i = typeof s == "number" || typeof s == "string" ? String(s) : void 0, a = this.buildUrl(e, t, i), c = {
|
||||||
|
operation: "read",
|
||||||
|
id: Array.isArray(s) ? s : void 0,
|
||||||
|
options: r
|
||||||
|
};
|
||||||
|
return this.fetchWithError(a, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(c)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async create(e, t, s, r) {
|
||||||
|
const i = this.buildUrl(e, t), a = {
|
||||||
|
operation: "create",
|
||||||
|
data: s,
|
||||||
|
options: r
|
||||||
|
};
|
||||||
|
return this.fetchWithError(i, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(a)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async update(e, t, s, r, i) {
|
||||||
|
const a = typeof r == "number" || typeof r == "string" ? String(r) : void 0, c = this.buildUrl(e, t, a), o = {
|
||||||
|
operation: "update",
|
||||||
|
id: Array.isArray(r) ? r : void 0,
|
||||||
|
data: s,
|
||||||
|
options: i
|
||||||
|
};
|
||||||
|
return this.fetchWithError(c, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(o)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async delete(e, t, s) {
|
||||||
|
const r = this.buildUrl(e, t, String(s)), i = {
|
||||||
|
operation: "delete"
|
||||||
|
};
|
||||||
|
return this.fetchWithError(r, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(i)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const f = /* @__PURE__ */ new Map();
|
||||||
|
function _(n) {
|
||||||
|
const e = n.url;
|
||||||
|
let t = f.get(e);
|
||||||
|
return t || (t = new w(n), f.set(e, t)), t;
|
||||||
|
}
|
||||||
|
class w {
|
||||||
|
constructor(e) {
|
||||||
|
this.ws = null, this.messageHandlers = /* @__PURE__ */ new Map(), this.subscriptions = /* @__PURE__ */ new Map(), this.eventListeners = {}, this.state = "disconnected", this.reconnectAttempts = 0, this.reconnectTimer = null, this.heartbeatTimer = null, this.isManualClose = !1, this.config = {
|
||||||
|
url: e.url,
|
||||||
|
reconnect: e.reconnect ?? !0,
|
||||||
|
reconnectInterval: e.reconnectInterval ?? 3e3,
|
||||||
|
maxReconnectAttempts: e.maxReconnectAttempts ?? 10,
|
||||||
|
heartbeatInterval: e.heartbeatInterval ?? 3e4,
|
||||||
|
debug: e.debug ?? !1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async connect() {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.log("Already connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.isManualClose = !1, this.setState("connecting"), new Promise((e, t) => {
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.config.url), this.ws.onopen = () => {
|
||||||
|
this.log("Connected to WebSocket server"), this.setState("connected"), this.reconnectAttempts = 0, this.startHeartbeat(), this.emit("connect"), e();
|
||||||
|
}, this.ws.onmessage = (s) => {
|
||||||
|
this.handleMessage(s.data);
|
||||||
|
}, this.ws.onerror = (s) => {
|
||||||
|
this.log("WebSocket error:", s);
|
||||||
|
const r = new Error("WebSocket connection error");
|
||||||
|
this.emit("error", r), t(r);
|
||||||
|
}, this.ws.onclose = (s) => {
|
||||||
|
this.log("WebSocket closed:", s.code, s.reason), this.stopHeartbeat(), this.setState("disconnected"), this.emit("disconnect", s), this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts && (this.reconnectAttempts++, this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`), this.setState("reconnecting"), this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.connect().catch((r) => {
|
||||||
|
this.log("Reconnection failed:", r);
|
||||||
|
});
|
||||||
|
}, this.config.reconnectInterval));
|
||||||
|
};
|
||||||
|
} catch (s) {
|
||||||
|
t(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
this.isManualClose = !0, this.reconnectTimer && (clearTimeout(this.reconnectTimer), this.reconnectTimer = null), this.stopHeartbeat(), this.ws && (this.setState("disconnecting"), this.ws.close(), this.ws = null), this.setState("disconnected"), this.messageHandlers.clear();
|
||||||
|
}
|
||||||
|
async request(e, t, s) {
|
||||||
|
this.ensureConnected();
|
||||||
|
const r = l(), i = {
|
||||||
|
id: r,
|
||||||
|
type: "request",
|
||||||
|
operation: e,
|
||||||
|
entity: t,
|
||||||
|
schema: s?.schema,
|
||||||
|
record_id: s?.record_id,
|
||||||
|
data: s?.data,
|
||||||
|
options: s?.options
|
||||||
|
};
|
||||||
|
return new Promise((a, c) => {
|
||||||
|
this.messageHandlers.set(r, (o) => {
|
||||||
|
o.success ? a(o.data) : c(new Error(o.error?.message || "Request failed"));
|
||||||
|
}), this.send(i), setTimeout(() => {
|
||||||
|
this.messageHandlers.has(r) && (this.messageHandlers.delete(r), c(new Error("Request timeout")));
|
||||||
|
}, 3e4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async read(e, t) {
|
||||||
|
return this.request("read", e, {
|
||||||
|
schema: t?.schema,
|
||||||
|
record_id: t?.record_id,
|
||||||
|
options: {
|
||||||
|
filters: t?.filters,
|
||||||
|
columns: t?.columns,
|
||||||
|
sort: t?.sort,
|
||||||
|
preload: t?.preload,
|
||||||
|
limit: t?.limit,
|
||||||
|
offset: t?.offset
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async create(e, t, s) {
|
||||||
|
return this.request("create", e, {
|
||||||
|
schema: s?.schema,
|
||||||
|
data: t
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async update(e, t, s, r) {
|
||||||
|
return this.request("update", e, {
|
||||||
|
schema: r?.schema,
|
||||||
|
record_id: t,
|
||||||
|
data: s
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async delete(e, t, s) {
|
||||||
|
await this.request("delete", e, {
|
||||||
|
schema: s?.schema,
|
||||||
|
record_id: t
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async meta(e, t) {
|
||||||
|
return this.request("meta", e, {
|
||||||
|
schema: t?.schema
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async subscribe(e, t, s) {
|
||||||
|
this.ensureConnected();
|
||||||
|
const r = l(), i = {
|
||||||
|
id: r,
|
||||||
|
type: "subscription",
|
||||||
|
operation: "subscribe",
|
||||||
|
entity: e,
|
||||||
|
schema: s?.schema,
|
||||||
|
options: {
|
||||||
|
filters: s?.filters
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return new Promise((a, c) => {
|
||||||
|
this.messageHandlers.set(r, (o) => {
|
||||||
|
if (o.success && o.data?.subscription_id) {
|
||||||
|
const h = o.data.subscription_id;
|
||||||
|
this.subscriptions.set(h, {
|
||||||
|
id: h,
|
||||||
|
entity: e,
|
||||||
|
schema: s?.schema,
|
||||||
|
options: { filters: s?.filters },
|
||||||
|
callback: t
|
||||||
|
}), this.log(`Subscribed to ${e} with ID: ${h}`), a(h);
|
||||||
|
} else
|
||||||
|
c(new Error(o.error?.message || "Subscription failed"));
|
||||||
|
}), this.send(i), setTimeout(() => {
|
||||||
|
this.messageHandlers.has(r) && (this.messageHandlers.delete(r), c(new Error("Subscription timeout")));
|
||||||
|
}, 1e4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async unsubscribe(e) {
|
||||||
|
this.ensureConnected();
|
||||||
|
const t = l(), s = {
|
||||||
|
id: t,
|
||||||
|
type: "subscription",
|
||||||
|
operation: "unsubscribe",
|
||||||
|
subscription_id: e
|
||||||
|
};
|
||||||
|
return new Promise((r, i) => {
|
||||||
|
this.messageHandlers.set(t, (a) => {
|
||||||
|
a.success ? (this.subscriptions.delete(e), this.log(`Unsubscribed from ${e}`), r()) : i(new Error(a.error?.message || "Unsubscribe failed"));
|
||||||
|
}), this.send(s), setTimeout(() => {
|
||||||
|
this.messageHandlers.has(t) && (this.messageHandlers.delete(t), i(new Error("Unsubscribe timeout")));
|
||||||
|
}, 1e4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getSubscriptions() {
|
||||||
|
return Array.from(this.subscriptions.values());
|
||||||
|
}
|
||||||
|
getState() {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
isConnected() {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
on(e, t) {
|
||||||
|
this.eventListeners[e] = t;
|
||||||
|
}
|
||||||
|
off(e) {
|
||||||
|
delete this.eventListeners[e];
|
||||||
|
}
|
||||||
|
// Private methods
|
||||||
|
handleMessage(e) {
|
||||||
|
try {
|
||||||
|
const t = JSON.parse(e);
|
||||||
|
switch (this.log("Received message:", t), this.emit("message", t), t.type) {
|
||||||
|
case "response":
|
||||||
|
this.handleResponse(t);
|
||||||
|
break;
|
||||||
|
case "notification":
|
||||||
|
this.handleNotification(t);
|
||||||
|
break;
|
||||||
|
case "pong":
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.log("Unknown message type:", t.type);
|
||||||
|
}
|
||||||
|
} catch (t) {
|
||||||
|
this.log("Error parsing message:", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleResponse(e) {
|
||||||
|
const t = this.messageHandlers.get(e.id);
|
||||||
|
t && (t(e), this.messageHandlers.delete(e.id));
|
||||||
|
}
|
||||||
|
handleNotification(e) {
|
||||||
|
const t = this.subscriptions.get(e.subscription_id);
|
||||||
|
t?.callback && t.callback(e);
|
||||||
|
}
|
||||||
|
send(e) {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
||||||
|
throw new Error("WebSocket is not connected");
|
||||||
|
const t = JSON.stringify(e);
|
||||||
|
this.log("Sending message:", e), this.ws.send(t);
|
||||||
|
}
|
||||||
|
startHeartbeat() {
|
||||||
|
this.heartbeatTimer || (this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.isConnected()) {
|
||||||
|
const e = {
|
||||||
|
id: l(),
|
||||||
|
type: "ping"
|
||||||
|
};
|
||||||
|
this.send(e);
|
||||||
|
}
|
||||||
|
}, this.config.heartbeatInterval));
|
||||||
|
}
|
||||||
|
stopHeartbeat() {
|
||||||
|
this.heartbeatTimer && (clearInterval(this.heartbeatTimer), this.heartbeatTimer = null);
|
||||||
|
}
|
||||||
|
setState(e) {
|
||||||
|
this.state !== e && (this.state = e, this.emit("stateChange", e));
|
||||||
|
}
|
||||||
|
ensureConnected() {
|
||||||
|
if (!this.isConnected())
|
||||||
|
throw new Error("WebSocket is not connected. Call connect() first.");
|
||||||
|
}
|
||||||
|
emit(e, ...t) {
|
||||||
|
const s = this.eventListeners[e];
|
||||||
|
s && s(...t);
|
||||||
|
}
|
||||||
|
log(...e) {
|
||||||
|
this.config.debug && console.log("[WebSocketClient]", ...e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function C(n) {
|
||||||
|
return typeof btoa == "function" ? "ZIP_" + btoa(n) : "ZIP_" + Buffer.from(n, "utf-8").toString("base64");
|
||||||
|
}
|
||||||
|
function y(n) {
|
||||||
|
let e = n;
|
||||||
|
return e.startsWith("ZIP_") ? (e = e.slice(4).replace(/[\n\r ]/g, ""), e = m(e)) : e.startsWith("__") && (e = e.slice(2).replace(/[\n\r ]/g, ""), e = m(e)), (e.startsWith("ZIP_") || e.startsWith("__")) && (e = y(e)), e;
|
||||||
|
}
|
||||||
|
function m(n) {
|
||||||
|
return typeof atob == "function" ? atob(n) : Buffer.from(n, "base64").toString("utf-8");
|
||||||
|
}
|
||||||
|
function u(n) {
|
||||||
|
const e = {};
|
||||||
|
if (n.columns?.length && (e["X-Select-Fields"] = n.columns.join(",")), n.omit_columns?.length && (e["X-Not-Select-Fields"] = n.omit_columns.join(",")), n.filters?.length)
|
||||||
|
for (const t of n.filters) {
|
||||||
|
const s = t.logic_operator ?? "AND", r = p(t.operator), i = S(t);
|
||||||
|
t.operator === "eq" && s === "AND" ? e[`X-FieldFilter-${t.column}`] = i : s === "OR" ? e[`X-SearchOr-${r}-${t.column}`] = i : e[`X-SearchOp-${r}-${t.column}`] = i;
|
||||||
|
}
|
||||||
|
if (n.sort?.length) {
|
||||||
|
const t = n.sort.map((s) => s.direction.toUpperCase() === "DESC" ? `-${s.column}` : `+${s.column}`);
|
||||||
|
e["X-Sort"] = t.join(",");
|
||||||
|
}
|
||||||
|
if (n.limit !== void 0 && (e["X-Limit"] = String(n.limit)), n.offset !== void 0 && (e["X-Offset"] = String(n.offset)), n.cursor_forward && (e["X-Cursor-Forward"] = n.cursor_forward), n.cursor_backward && (e["X-Cursor-Backward"] = n.cursor_backward), n.preload?.length) {
|
||||||
|
const t = n.preload.map((s) => s.columns?.length ? `${s.relation}:${s.columns.join(",")}` : s.relation);
|
||||||
|
e["X-Preload"] = t.join("|");
|
||||||
|
}
|
||||||
|
if (n.fetch_row_number && (e["X-Fetch-RowNumber"] = n.fetch_row_number), n.computedColumns?.length)
|
||||||
|
for (const t of n.computedColumns)
|
||||||
|
e[`X-CQL-SEL-${t.name}`] = t.expression;
|
||||||
|
if (n.customOperators?.length) {
|
||||||
|
const t = n.customOperators.map((s) => s.sql);
|
||||||
|
e["X-Custom-SQL-W"] = t.join(" AND ");
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function p(n) {
|
||||||
|
switch (n) {
|
||||||
|
case "eq":
|
||||||
|
return "equals";
|
||||||
|
case "neq":
|
||||||
|
return "notequals";
|
||||||
|
case "gt":
|
||||||
|
return "greaterthan";
|
||||||
|
case "gte":
|
||||||
|
return "greaterthanorequal";
|
||||||
|
case "lt":
|
||||||
|
return "lessthan";
|
||||||
|
case "lte":
|
||||||
|
return "lessthanorequal";
|
||||||
|
case "like":
|
||||||
|
case "ilike":
|
||||||
|
case "contains":
|
||||||
|
return "contains";
|
||||||
|
case "startswith":
|
||||||
|
return "beginswith";
|
||||||
|
case "endswith":
|
||||||
|
return "endswith";
|
||||||
|
case "in":
|
||||||
|
return "in";
|
||||||
|
case "between":
|
||||||
|
return "between";
|
||||||
|
case "between_inclusive":
|
||||||
|
return "betweeninclusive";
|
||||||
|
case "is_null":
|
||||||
|
return "empty";
|
||||||
|
case "is_not_null":
|
||||||
|
return "notempty";
|
||||||
|
default:
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function S(n) {
|
||||||
|
return n.value === null || n.value === void 0 ? "" : Array.isArray(n.value) ? n.value.join(",") : String(n.value);
|
||||||
|
}
|
||||||
|
const b = /* @__PURE__ */ new Map();
|
||||||
|
function v(n) {
|
||||||
|
const e = n.baseUrl;
|
||||||
|
let t = b.get(e);
|
||||||
|
return t || (t = new H(n), b.set(e, t)), t;
|
||||||
|
}
|
||||||
|
class H {
|
||||||
|
constructor(e) {
|
||||||
|
this.config = e;
|
||||||
|
}
|
||||||
|
buildUrl(e, t, s) {
|
||||||
|
let r = `${this.config.baseUrl}/${e}/${t}`;
|
||||||
|
return s && (r += `/${s}`), r;
|
||||||
|
}
|
||||||
|
baseHeaders() {
|
||||||
|
const e = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
};
|
||||||
|
return this.config.token && (e.Authorization = `Bearer ${this.config.token}`), e;
|
||||||
|
}
|
||||||
|
async fetchWithError(e, t) {
|
||||||
|
const s = await fetch(e, t), r = await s.json();
|
||||||
|
if (!s.ok)
|
||||||
|
throw new Error(r.error?.message || "An error occurred");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
async read(e, t, s, r) {
|
||||||
|
const i = this.buildUrl(e, t, s), a = r ? u(r) : {};
|
||||||
|
return this.fetchWithError(i, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { ...this.baseHeaders(), ...a }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async create(e, t, s, r) {
|
||||||
|
const i = this.buildUrl(e, t), a = r ? u(r) : {};
|
||||||
|
return this.fetchWithError(i, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...this.baseHeaders(), ...a },
|
||||||
|
body: JSON.stringify(s)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async update(e, t, s, r, i) {
|
||||||
|
const a = this.buildUrl(e, t, s), c = i ? u(i) : {};
|
||||||
|
return this.fetchWithError(a, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...this.baseHeaders(), ...c },
|
||||||
|
body: JSON.stringify(r)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async delete(e, t, s) {
|
||||||
|
const r = this.buildUrl(e, t, s);
|
||||||
|
return this.fetchWithError(r, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: this.baseHeaders()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
H as HeaderSpecClient,
|
||||||
|
g as ResolveSpecClient,
|
||||||
|
w as WebSocketClient,
|
||||||
|
u as buildHeaders,
|
||||||
|
y as decodeHeaderValue,
|
||||||
|
C as encodeHeaderValue,
|
||||||
|
v as getHeaderSpecClient,
|
||||||
|
E as getResolveSpecClient,
|
||||||
|
_ as getWebSocketClient
|
||||||
|
};
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "@warkypublic/resolvespec-js",
|
"name": "@warkypublic/resolvespec-js",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Client side library for the ResolveSpec API",
|
"description": "TypeScript client library for ResolveSpec REST, HeaderSpec, and WebSocket APIs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./dist/index.cjs",
|
||||||
"module": "./src/index.ts",
|
"module": "./dist/index.js",
|
||||||
"types": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public"
|
||||||
"main": "./dist/index.js",
|
|
||||||
"module": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"bin",
|
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,38 +28,33 @@
|
|||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"string",
|
"resolvespec",
|
||||||
"blob",
|
"headerspec",
|
||||||
"dependencies",
|
"websocket",
|
||||||
"workspace",
|
"rest-client",
|
||||||
"package",
|
"typescript",
|
||||||
"cli",
|
"api-client"
|
||||||
"tools",
|
|
||||||
"npm",
|
|
||||||
"yarn",
|
|
||||||
"pnpm"
|
|
||||||
],
|
],
|
||||||
"author": "Hein (Warkanum) Puth",
|
"author": "Hein (Warkanum) Puth",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.6.3",
|
"uuid": "^13.0.0"
|
||||||
"uuid": "^11.0.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.27.10",
|
"@changesets/cli": "^2.29.8",
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^27.0.0",
|
||||||
"eslint": "^9.16.0",
|
"eslint": "^10.0.0",
|
||||||
"globals": "^15.13.0",
|
"globals": "^17.3.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.17.0",
|
"typescript-eslint": "^8.55.0",
|
||||||
"vite": "^6.0.2",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-dts": "^4.3.0",
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.16"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
3376
resolvespec-js/pnpm-lock.yaml
generated
Normal file
3376
resolvespec-js/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
143
resolvespec-js/src/__tests__/common.test.ts
Normal file
143
resolvespec-js/src/__tests__/common.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type {
|
||||||
|
Options,
|
||||||
|
FilterOption,
|
||||||
|
SortOption,
|
||||||
|
PreloadOption,
|
||||||
|
RequestBody,
|
||||||
|
APIResponse,
|
||||||
|
Metadata,
|
||||||
|
APIError,
|
||||||
|
Parameter,
|
||||||
|
ComputedColumn,
|
||||||
|
CustomOperator,
|
||||||
|
} from '../common/types';
|
||||||
|
|
||||||
|
describe('Common Types', () => {
|
||||||
|
it('should construct a valid FilterOption with logic_operator', () => {
|
||||||
|
const filter: FilterOption = {
|
||||||
|
column: 'name',
|
||||||
|
operator: 'eq',
|
||||||
|
value: 'test',
|
||||||
|
logic_operator: 'OR',
|
||||||
|
};
|
||||||
|
expect(filter.logic_operator).toBe('OR');
|
||||||
|
expect(filter.operator).toBe('eq');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct Options with all new fields', () => {
|
||||||
|
const opts: Options = {
|
||||||
|
columns: ['id', 'name'],
|
||||||
|
omit_columns: ['secret'],
|
||||||
|
filters: [{ column: 'age', operator: 'gte', value: 18 }],
|
||||||
|
sort: [{ column: 'name', direction: 'asc' }],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
cursor_forward: 'abc123',
|
||||||
|
cursor_backward: 'xyz789',
|
||||||
|
fetch_row_number: '42',
|
||||||
|
parameters: [{ name: 'param1', value: 'val1', sequence: 1 }],
|
||||||
|
computedColumns: [{ name: 'full_name', expression: "first || ' ' || last" }],
|
||||||
|
customOperators: [{ name: 'custom', sql: "status = 'active'" }],
|
||||||
|
preload: [{
|
||||||
|
relation: 'Items',
|
||||||
|
columns: ['id', 'title'],
|
||||||
|
omit_columns: ['internal'],
|
||||||
|
sort: [{ column: 'id', direction: 'ASC' }],
|
||||||
|
recursive: true,
|
||||||
|
primary_key: 'id',
|
||||||
|
related_key: 'parent_id',
|
||||||
|
sql_joins: ['LEFT JOIN other ON other.id = items.other_id'],
|
||||||
|
join_aliases: ['other'],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
expect(opts.omit_columns).toEqual(['secret']);
|
||||||
|
expect(opts.cursor_forward).toBe('abc123');
|
||||||
|
expect(opts.fetch_row_number).toBe('42');
|
||||||
|
expect(opts.parameters![0].sequence).toBe(1);
|
||||||
|
expect(opts.preload![0].recursive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct a RequestBody with numeric id', () => {
|
||||||
|
const body: RequestBody = {
|
||||||
|
operation: 'read',
|
||||||
|
id: 42,
|
||||||
|
options: { limit: 10 },
|
||||||
|
};
|
||||||
|
expect(body.id).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct a RequestBody with string array id', () => {
|
||||||
|
const body: RequestBody = {
|
||||||
|
operation: 'delete',
|
||||||
|
id: ['1', '2', '3'],
|
||||||
|
};
|
||||||
|
expect(Array.isArray(body.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct Metadata with count and row_number', () => {
|
||||||
|
const meta: Metadata = {
|
||||||
|
total: 100,
|
||||||
|
count: 10,
|
||||||
|
filtered: 50,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
row_number: 5,
|
||||||
|
};
|
||||||
|
expect(meta.count).toBe(10);
|
||||||
|
expect(meta.row_number).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct APIError with detail field', () => {
|
||||||
|
const err: APIError = {
|
||||||
|
code: 'not_found',
|
||||||
|
message: 'Record not found',
|
||||||
|
detail: 'The record with id 42 does not exist',
|
||||||
|
};
|
||||||
|
expect(err.detail).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct APIResponse with metadata', () => {
|
||||||
|
const resp: APIResponse<string[]> = {
|
||||||
|
success: true,
|
||||||
|
data: ['a', 'b'],
|
||||||
|
metadata: { total: 2, count: 2, filtered: 2, limit: 10, offset: 0 },
|
||||||
|
};
|
||||||
|
expect(resp.metadata?.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support all operator types', () => {
|
||||||
|
const operators: FilterOption['operator'][] = [
|
||||||
|
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
|
||||||
|
'like', 'ilike', 'in',
|
||||||
|
'contains', 'startswith', 'endswith',
|
||||||
|
'between', 'between_inclusive',
|
||||||
|
'is_null', 'is_not_null',
|
||||||
|
];
|
||||||
|
for (const op of operators) {
|
||||||
|
const f: FilterOption = { column: 'x', operator: op, value: 'v' };
|
||||||
|
expect(f.operator).toBe(op);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support PreloadOption with computed_ql and where', () => {
|
||||||
|
const preload: PreloadOption = {
|
||||||
|
relation: 'Details',
|
||||||
|
where: "status = 'active'",
|
||||||
|
computed_ql: { cql1: 'SUM(amount)' },
|
||||||
|
table_name: 'detail_table',
|
||||||
|
updatable: true,
|
||||||
|
foreign_key: 'detail_id',
|
||||||
|
recursive_child_key: 'parent_detail_id',
|
||||||
|
};
|
||||||
|
expect(preload.computed_ql?.cql1).toBe('SUM(amount)');
|
||||||
|
expect(preload.updatable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support Parameter interface', () => {
|
||||||
|
const p: Parameter = { name: 'key', value: 'val' };
|
||||||
|
expect(p.name).toBe('key');
|
||||||
|
const p2: Parameter = { name: 'key2', value: 'val2', sequence: 5 };
|
||||||
|
expect(p2.sequence).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
239
resolvespec-js/src/__tests__/headerspec.test.ts
Normal file
239
resolvespec-js/src/__tests__/headerspec.test.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { buildHeaders, encodeHeaderValue, decodeHeaderValue, HeaderSpecClient, getHeaderSpecClient } from '../headerspec/client';
|
||||||
|
import type { Options, ClientConfig, APIResponse } from '../common/types';
|
||||||
|
|
||||||
|
describe('buildHeaders', () => {
|
||||||
|
it('should set X-Select-Fields for columns', () => {
|
||||||
|
const h = buildHeaders({ columns: ['id', 'name', 'email'] });
|
||||||
|
expect(h['X-Select-Fields']).toBe('id,name,email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-Not-Select-Fields for omit_columns', () => {
|
||||||
|
const h = buildHeaders({ omit_columns: ['secret', 'internal'] });
|
||||||
|
expect(h['X-Not-Select-Fields']).toBe('secret,internal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-FieldFilter for eq AND filters', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
filters: [{ column: 'status', operator: 'eq', value: 'active' }],
|
||||||
|
});
|
||||||
|
expect(h['X-FieldFilter-status']).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-SearchOp for non-eq AND filters', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
filters: [{ column: 'age', operator: 'gte', value: 18 }],
|
||||||
|
});
|
||||||
|
expect(h['X-SearchOp-greaterthanorequal-age']).toBe('18');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-SearchOr for OR filters', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
filters: [{ column: 'name', operator: 'contains', value: 'test', logic_operator: 'OR' }],
|
||||||
|
});
|
||||||
|
expect(h['X-SearchOr-contains-name']).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-Sort with direction prefixes', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
sort: [
|
||||||
|
{ column: 'name', direction: 'asc' },
|
||||||
|
{ column: 'created_at', direction: 'DESC' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(h['X-Sort']).toBe('+name,-created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-Limit and X-Offset', () => {
|
||||||
|
const h = buildHeaders({ limit: 25, offset: 50 });
|
||||||
|
expect(h['X-Limit']).toBe('25');
|
||||||
|
expect(h['X-Offset']).toBe('50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set cursor pagination headers', () => {
|
||||||
|
const h = buildHeaders({ cursor_forward: 'abc', cursor_backward: 'xyz' });
|
||||||
|
expect(h['X-Cursor-Forward']).toBe('abc');
|
||||||
|
expect(h['X-Cursor-Backward']).toBe('xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-Preload with pipe-separated relations', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
preload: [
|
||||||
|
{ relation: 'Items', columns: ['id', 'name'] },
|
||||||
|
{ relation: 'Category' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(h['X-Preload']).toBe('Items:id,name|Category');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-Fetch-RowNumber', () => {
|
||||||
|
const h = buildHeaders({ fetch_row_number: '42' });
|
||||||
|
expect(h['X-Fetch-RowNumber']).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-CQL-SEL for computed columns', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
computedColumns: [
|
||||||
|
{ name: 'total', expression: 'price * qty' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(h['X-CQL-SEL-total']).toBe('price * qty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set X-Custom-SQL-W for custom operators', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
customOperators: [
|
||||||
|
{ name: 'active', sql: "status = 'active'" },
|
||||||
|
{ name: 'verified', sql: "verified = true" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(h['X-Custom-SQL-W']).toBe("status = 'active' AND verified = true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object for empty options', () => {
|
||||||
|
const h = buildHeaders({});
|
||||||
|
expect(Object.keys(h)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle between filter with array value', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
filters: [{ column: 'price', operator: 'between', value: [10, 100] }],
|
||||||
|
});
|
||||||
|
expect(h['X-SearchOp-between-price']).toBe('10,100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle is_null filter with null value', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
filters: [{ column: 'deleted_at', operator: 'is_null', value: null }],
|
||||||
|
});
|
||||||
|
expect(h['X-SearchOp-empty-deleted_at']).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle in filter with array value', () => {
|
||||||
|
const h = buildHeaders({
|
||||||
|
filters: [{ column: 'id', operator: 'in', value: [1, 2, 3] }],
|
||||||
|
});
|
||||||
|
expect(h['X-SearchOp-in-id']).toBe('1,2,3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeHeaderValue / decodeHeaderValue', () => {
|
||||||
|
it('should round-trip encode/decode', () => {
|
||||||
|
const original = 'some complex value with spaces & symbols!';
|
||||||
|
const encoded = encodeHeaderValue(original);
|
||||||
|
expect(encoded.startsWith('ZIP_')).toBe(true);
|
||||||
|
const decoded = decodeHeaderValue(encoded);
|
||||||
|
expect(decoded).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should decode __ prefixed values', () => {
|
||||||
|
const encoded = '__' + btoa('hello');
|
||||||
|
expect(decodeHeaderValue(encoded)).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return plain values as-is', () => {
|
||||||
|
expect(decodeHeaderValue('plain')).toBe('plain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HeaderSpecClient', () => {
|
||||||
|
const config: ClientConfig = { baseUrl: 'http://localhost:3000', token: 'tok' };
|
||||||
|
|
||||||
|
function mockFetch<T>(data: APIResponse<T>, ok = true) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
json: () => Promise.resolve(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read() sends GET with headers from options', async () => {
|
||||||
|
globalThis.fetch = mockFetch({ success: true, data: [{ id: 1 }] });
|
||||||
|
const client = new HeaderSpecClient(config);
|
||||||
|
|
||||||
|
await client.read('public', 'users', undefined, {
|
||||||
|
columns: ['id', 'name'],
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:3000/public/users');
|
||||||
|
expect(opts.method).toBe('GET');
|
||||||
|
expect(opts.headers['X-Select-Fields']).toBe('id,name');
|
||||||
|
expect(opts.headers['X-Limit']).toBe('10');
|
||||||
|
expect(opts.headers['Authorization']).toBe('Bearer tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read() with id appends to URL', async () => {
|
||||||
|
globalThis.fetch = mockFetch({ success: true, data: {} });
|
||||||
|
const client = new HeaderSpecClient(config);
|
||||||
|
|
||||||
|
await client.read('public', 'users', '42');
|
||||||
|
|
||||||
|
const [url] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:3000/public/users/42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create() sends POST with body and headers', async () => {
|
||||||
|
globalThis.fetch = mockFetch({ success: true, data: { id: 1 } });
|
||||||
|
const client = new HeaderSpecClient(config);
|
||||||
|
|
||||||
|
await client.create('public', 'users', { name: 'Test' });
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(JSON.parse(opts.body)).toEqual({ name: 'Test' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update() sends PUT with id in URL', async () => {
|
||||||
|
globalThis.fetch = mockFetch({ success: true, data: {} });
|
||||||
|
const client = new HeaderSpecClient(config);
|
||||||
|
|
||||||
|
await client.update('public', 'users', '1', { name: 'Updated' }, {
|
||||||
|
filters: [{ column: 'active', operator: 'eq', value: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:3000/public/users/1');
|
||||||
|
expect(opts.method).toBe('PUT');
|
||||||
|
expect(opts.headers['X-FieldFilter-active']).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete() sends DELETE', async () => {
|
||||||
|
globalThis.fetch = mockFetch({ success: true, data: undefined as any });
|
||||||
|
const client = new HeaderSpecClient(config);
|
||||||
|
|
||||||
|
await client.delete('public', 'users', '1');
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:3000/public/users/1');
|
||||||
|
expect(opts.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on non-ok response', async () => {
|
||||||
|
globalThis.fetch = mockFetch(
|
||||||
|
{ success: false, data: null as any, error: { code: 'err', message: 'fail' } },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const client = new HeaderSpecClient(config);
|
||||||
|
|
||||||
|
await expect(client.read('public', 'users')).rejects.toThrow('fail');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getHeaderSpecClient singleton', () => {
|
||||||
|
it('returns same instance for same baseUrl', () => {
|
||||||
|
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
|
||||||
|
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns different instances for different baseUrls', () => {
|
||||||
|
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-a:3000' });
|
||||||
|
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-b:3000' });
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
178
resolvespec-js/src/__tests__/resolvespec.test.ts
Normal file
178
resolvespec-js/src/__tests__/resolvespec.test.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ResolveSpecClient, getResolveSpecClient } from '../resolvespec/client';
|
||||||
|
import type { ClientConfig, APIResponse } from '../common/types';
|
||||||
|
|
||||||
|
const config: ClientConfig = { baseUrl: 'http://localhost:3000', token: 'test-token' };
|
||||||
|
|
||||||
|
function mockFetchResponse<T>(data: APIResponse<T>, ok = true, status = 200) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResolveSpecClient', () => {
|
||||||
|
it('read() sends POST with operation read', async () => {
|
||||||
|
const response: APIResponse = { success: true, data: [{ id: 1 }] };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
const result = await client.read('public', 'users', 1);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:3000/public/users/1');
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(opts.headers['Authorization']).toBe('Bearer test-token');
|
||||||
|
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.operation).toBe('read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read() with string array id puts id in body', async () => {
|
||||||
|
const response: APIResponse = { success: true, data: [] };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
await client.read('public', 'users', ['1', '2']);
|
||||||
|
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.id).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read() passes options through', async () => {
|
||||||
|
const response: APIResponse = { success: true, data: [] };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
await client.read('public', 'users', undefined, {
|
||||||
|
columns: ['id', 'name'],
|
||||||
|
omit_columns: ['secret'],
|
||||||
|
filters: [{ column: 'active', operator: 'eq', value: true }],
|
||||||
|
sort: [{ column: 'name', direction: 'asc' }],
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
cursor_forward: 'cursor1',
|
||||||
|
fetch_row_number: '5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.options.columns).toEqual(['id', 'name']);
|
||||||
|
expect(body.options.omit_columns).toEqual(['secret']);
|
||||||
|
expect(body.options.cursor_forward).toBe('cursor1');
|
||||||
|
expect(body.options.fetch_row_number).toBe('5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create() sends POST with operation create and data', async () => {
|
||||||
|
const response: APIResponse = { success: true, data: { id: 1, name: 'Test' } };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
const result = await client.create('public', 'users', { name: 'Test' });
|
||||||
|
expect(result.data.name).toBe('Test');
|
||||||
|
|
||||||
|
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.operation).toBe('create');
|
||||||
|
expect(body.data.name).toBe('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update() with single id puts id in URL', async () => {
|
||||||
|
const response: APIResponse = { success: true, data: { id: 1 } };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
await client.update('public', 'users', { name: 'Updated' }, 1);
|
||||||
|
const [url] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:3000/public/users/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update() with string array id puts id in body', async () => {
|
||||||
|
const response: APIResponse = { success: true, data: {} };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
await client.update('public', 'users', { active: false }, ['1', '2']);
|
||||||
|
const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.id).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete() sends POST with operation delete', async () => {
|
||||||
|
const response: APIResponse<void> = { success: true, data: undefined as any };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
await client.delete('public', 'users', 1);
|
||||||
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
||||||
|
expect(url).toBe('http://localhost:3000/public/users/1');
|
||||||
|
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.operation).toBe('delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMetadata() sends GET request', async () => {
|
||||||
|
const response: APIResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { schema: 'public', table: 'users', columns: [], relations: [] },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
const result = await client.getMetadata('public', 'users');
|
||||||
|
expect(result.data.table).toBe('users');
|
||||||
|
|
||||||
|
const opts = (globalThis.fetch as any).mock.calls[0][1];
|
||||||
|
expect(opts.method).toBe('GET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on non-ok response', async () => {
|
||||||
|
const errorResp = {
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
error: { code: 'not_found', message: 'Not found' },
|
||||||
|
};
|
||||||
|
globalThis.fetch = mockFetchResponse(errorResp as any, false, 404);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
await expect(client.read('public', 'users', 999)).rejects.toThrow('Not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws generic error when no error message', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: () => Promise.resolve({ success: false, data: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(config);
|
||||||
|
await expect(client.read('public', 'users')).rejects.toThrow('An error occurred');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('config without token omits Authorization header', async () => {
|
||||||
|
const noAuthConfig: ClientConfig = { baseUrl: 'http://localhost:3000' };
|
||||||
|
const response: APIResponse = { success: true, data: [] };
|
||||||
|
globalThis.fetch = mockFetchResponse(response);
|
||||||
|
|
||||||
|
const client = new ResolveSpecClient(noAuthConfig);
|
||||||
|
await client.read('public', 'users');
|
||||||
|
const opts = (globalThis.fetch as any).mock.calls[0][1];
|
||||||
|
expect(opts.headers['Authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResolveSpecClient singleton', () => {
|
||||||
|
it('returns same instance for same baseUrl', () => {
|
||||||
|
const a = getResolveSpecClient({ baseUrl: 'http://singleton-test:3000' });
|
||||||
|
const b = getResolveSpecClient({ baseUrl: 'http://singleton-test:3000' });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns different instances for different baseUrls', () => {
|
||||||
|
const a = getResolveSpecClient({ baseUrl: 'http://singleton-a:3000' });
|
||||||
|
const b = getResolveSpecClient({ baseUrl: 'http://singleton-b:3000' });
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
336
resolvespec-js/src/__tests__/websocketspec.test.ts
Normal file
336
resolvespec-js/src/__tests__/websocketspec.test.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { WebSocketClient, getWebSocketClient } from '../websocketspec/client';
|
||||||
|
import type { WebSocketClientConfig } from '../websocketspec/types';
|
||||||
|
|
||||||
|
// Mock uuid
|
||||||
|
vi.mock('uuid', () => ({
|
||||||
|
v4: vi.fn(() => 'mock-uuid-1234'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock WebSocket
|
||||||
|
class MockWebSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static CLOSED = 3;
|
||||||
|
|
||||||
|
url: string;
|
||||||
|
readyState = MockWebSocket.OPEN;
|
||||||
|
onopen: ((ev: any) => void) | null = null;
|
||||||
|
onclose: ((ev: any) => void) | null = null;
|
||||||
|
onmessage: ((ev: any) => void) | null = null;
|
||||||
|
onerror: ((ev: any) => void) | null = null;
|
||||||
|
|
||||||
|
private sentMessages: string[] = [];
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
// Simulate async open
|
||||||
|
setTimeout(() => {
|
||||||
|
this.onopen?.({});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string) {
|
||||||
|
this.sentMessages.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.readyState = MockWebSocket.CLOSED;
|
||||||
|
this.onclose?.({ code: 1000, reason: 'Normal closure' } as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSentMessages(): any[] {
|
||||||
|
return this.sentMessages.map((m) => JSON.parse(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateMessage(data: any) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockWsInstance: MockWebSocket | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockWsInstance = null;
|
||||||
|
(globalThis as any).WebSocket = class extends MockWebSocket {
|
||||||
|
constructor(url: string) {
|
||||||
|
super(url);
|
||||||
|
mockWsInstance = this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(globalThis as any).WebSocket.OPEN = MockWebSocket.OPEN;
|
||||||
|
(globalThis as any).WebSocket.CLOSED = MockWebSocket.CLOSED;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebSocketClient', () => {
|
||||||
|
const wsConfig: WebSocketClientConfig = {
|
||||||
|
url: 'ws://localhost:8080',
|
||||||
|
reconnect: false,
|
||||||
|
heartbeatInterval: 60000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should connect and set state to connected', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
expect(client.getState()).toBe('connected');
|
||||||
|
expect(client.isConnected()).toBe(true);
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disconnect and set state to disconnected', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
client.disconnect();
|
||||||
|
expect(client.getState()).toBe('disconnected');
|
||||||
|
expect(client.isConnected()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send read request', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const readPromise = client.read('users', {
|
||||||
|
schema: 'public',
|
||||||
|
filters: [{ column: 'active', operator: 'eq', value: true }],
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate server response
|
||||||
|
const sent = mockWsInstance!.getSentMessages();
|
||||||
|
expect(sent.length).toBe(1);
|
||||||
|
expect(sent[0].operation).toBe('read');
|
||||||
|
expect(sent[0].entity).toBe('users');
|
||||||
|
expect(sent[0].options.filters[0].column).toBe('active');
|
||||||
|
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[0].id,
|
||||||
|
type: 'response',
|
||||||
|
success: true,
|
||||||
|
data: [{ id: 1 }],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await readPromise;
|
||||||
|
expect(result).toEqual([{ id: 1 }]);
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send create request', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const createPromise = client.create('users', { name: 'Test' }, { schema: 'public' });
|
||||||
|
|
||||||
|
const sent = mockWsInstance!.getSentMessages();
|
||||||
|
expect(sent[0].operation).toBe('create');
|
||||||
|
expect(sent[0].data.name).toBe('Test');
|
||||||
|
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[0].id,
|
||||||
|
type: 'response',
|
||||||
|
success: true,
|
||||||
|
data: { id: 1, name: 'Test' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createPromise;
|
||||||
|
expect(result.name).toBe('Test');
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send update request with record_id', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const updatePromise = client.update('users', '1', { name: 'Updated' });
|
||||||
|
|
||||||
|
const sent = mockWsInstance!.getSentMessages();
|
||||||
|
expect(sent[0].operation).toBe('update');
|
||||||
|
expect(sent[0].record_id).toBe('1');
|
||||||
|
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[0].id,
|
||||||
|
type: 'response',
|
||||||
|
success: true,
|
||||||
|
data: { id: 1, name: 'Updated' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await updatePromise;
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send delete request', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const deletePromise = client.delete('users', '1');
|
||||||
|
|
||||||
|
const sent = mockWsInstance!.getSentMessages();
|
||||||
|
expect(sent[0].operation).toBe('delete');
|
||||||
|
expect(sent[0].record_id).toBe('1');
|
||||||
|
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[0].id,
|
||||||
|
type: 'response',
|
||||||
|
success: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await deletePromise;
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject on failed request', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const readPromise = client.read('users');
|
||||||
|
|
||||||
|
const sent = mockWsInstance!.getSentMessages();
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[0].id,
|
||||||
|
type: 'response',
|
||||||
|
success: false,
|
||||||
|
error: { code: 'not_found', message: 'Not found' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(readPromise).rejects.toThrow('Not found');
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle subscriptions', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const subPromise = client.subscribe('users', callback, {
|
||||||
|
schema: 'public',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = mockWsInstance!.getSentMessages();
|
||||||
|
expect(sent[0].type).toBe('subscription');
|
||||||
|
expect(sent[0].operation).toBe('subscribe');
|
||||||
|
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[0].id,
|
||||||
|
type: 'response',
|
||||||
|
success: true,
|
||||||
|
data: { subscription_id: 'sub-1' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const subId = await subPromise;
|
||||||
|
expect(subId).toBe('sub-1');
|
||||||
|
expect(client.getSubscriptions()).toHaveLength(1);
|
||||||
|
|
||||||
|
// Simulate notification
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
type: 'notification',
|
||||||
|
operation: 'create',
|
||||||
|
subscription_id: 'sub-1',
|
||||||
|
entity: 'users',
|
||||||
|
data: { id: 2, name: 'New' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback.mock.calls[0][0].data.id).toBe(2);
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unsubscribe', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Subscribe first
|
||||||
|
const subPromise = client.subscribe('users', vi.fn());
|
||||||
|
let sent = mockWsInstance!.getSentMessages();
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[0].id,
|
||||||
|
type: 'response',
|
||||||
|
success: true,
|
||||||
|
data: { subscription_id: 'sub-1' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await subPromise;
|
||||||
|
|
||||||
|
// Unsubscribe
|
||||||
|
const unsubPromise = client.unsubscribe('sub-1');
|
||||||
|
sent = mockWsInstance!.getSentMessages();
|
||||||
|
mockWsInstance!.simulateMessage({
|
||||||
|
id: sent[sent.length - 1].id,
|
||||||
|
type: 'response',
|
||||||
|
success: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await unsubPromise;
|
||||||
|
expect(client.getSubscriptions()).toHaveLength(0);
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit events', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
const connectCb = vi.fn();
|
||||||
|
const stateChangeCb = vi.fn();
|
||||||
|
|
||||||
|
client.on('connect', connectCb);
|
||||||
|
client.on('stateChange', stateChangeCb);
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
expect(connectCb).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stateChangeCb).toHaveBeenCalled();
|
||||||
|
|
||||||
|
client.off('connect');
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when sending without connection', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await expect(client.read('users')).rejects.toThrow('WebSocket is not connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pong messages without error', async () => {
|
||||||
|
const client = new WebSocketClient(wsConfig);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
mockWsInstance!.simulateMessage({ type: 'pong' });
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed messages gracefully', async () => {
|
||||||
|
const client = new WebSocketClient({ ...wsConfig, debug: false });
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Simulate non-JSON message
|
||||||
|
mockWsInstance!.onmessage?.({ data: 'not-json' } as any);
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWebSocketClient singleton', () => {
|
||||||
|
it('returns same instance for same url', () => {
|
||||||
|
const a = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
|
||||||
|
const b = getWebSocketClient({ url: 'ws://ws-singleton:8080' });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns different instances for different urls', () => {
|
||||||
|
const a = getWebSocketClient({ url: 'ws://ws-singleton-a:8080' });
|
||||||
|
const b = getWebSocketClient({ url: 'ws://ws-singleton-b:8080' });
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { ClientConfig, APIResponse, TableMetadata, Options, RequestBody } from "./types";
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
const getHeaders = (options?: Record<string,any>): HeadersInit => {
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options?.token) {
|
|
||||||
headers['Authorization'] = `Bearer ${options.token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildUrl = (config: ClientConfig, schema: string, entity: string, id?: string): string => {
|
|
||||||
let url = `${config.baseUrl}/${schema}/${entity}`;
|
|
||||||
if (id) {
|
|
||||||
url += `/${id}`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchWithError = async <T>(url: string, options: RequestInit): Promise<APIResponse<T>> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error?.message || 'An error occurred');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// API Functions
|
|
||||||
export const getMetadata = async (
|
|
||||||
config: ClientConfig,
|
|
||||||
schema: string,
|
|
||||||
entity: string
|
|
||||||
): Promise<APIResponse<TableMetadata>> => {
|
|
||||||
const url = buildUrl(config, schema, entity);
|
|
||||||
return fetchWithError<TableMetadata>(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getHeaders(config),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const read = async <T = any>(
|
|
||||||
config: ClientConfig,
|
|
||||||
schema: string,
|
|
||||||
entity: string,
|
|
||||||
id?: string,
|
|
||||||
options?: Options
|
|
||||||
): Promise<APIResponse<T>> => {
|
|
||||||
const url = buildUrl(config, schema, entity, id);
|
|
||||||
const body: RequestBody = {
|
|
||||||
operation: 'read',
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
return fetchWithError<T>(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(config),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async <T = any>(
|
|
||||||
config: ClientConfig,
|
|
||||||
schema: string,
|
|
||||||
entity: string,
|
|
||||||
data: any | any[],
|
|
||||||
options?: Options
|
|
||||||
): Promise<APIResponse<T>> => {
|
|
||||||
const url = buildUrl(config, schema, entity);
|
|
||||||
const body: RequestBody = {
|
|
||||||
operation: 'create',
|
|
||||||
data,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
return fetchWithError<T>(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(config),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const update = async <T = any>(
|
|
||||||
config: ClientConfig,
|
|
||||||
schema: string,
|
|
||||||
entity: string,
|
|
||||||
data: any | any[],
|
|
||||||
id?: string | string[],
|
|
||||||
options?: Options
|
|
||||||
): Promise<APIResponse<T>> => {
|
|
||||||
const url = buildUrl(config, schema, entity, typeof id === 'string' ? id : undefined);
|
|
||||||
const body: RequestBody = {
|
|
||||||
operation: 'update',
|
|
||||||
id: typeof id === 'string' ? undefined : id,
|
|
||||||
data,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
return fetchWithError<T>(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(config),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteEntity = async (
|
|
||||||
config: ClientConfig,
|
|
||||||
schema: string,
|
|
||||||
entity: string,
|
|
||||||
id: string
|
|
||||||
): Promise<APIResponse<void>> => {
|
|
||||||
const url = buildUrl(config, schema, entity, id);
|
|
||||||
const body: RequestBody = {
|
|
||||||
operation: 'delete',
|
|
||||||
};
|
|
||||||
|
|
||||||
return fetchWithError<void>(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(config),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
1
resolvespec-js/src/common/index.ts
Normal file
1
resolvespec-js/src/common/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './types';
|
||||||
129
resolvespec-js/src/common/types.ts
Normal file
129
resolvespec-js/src/common/types.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Types aligned with Go pkg/common/types.go
|
||||||
|
|
||||||
|
export type Operator =
|
||||||
|
| 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
|
||||||
|
| 'like' | 'ilike' | 'in'
|
||||||
|
| 'contains' | 'startswith' | 'endswith'
|
||||||
|
| 'between' | 'between_inclusive'
|
||||||
|
| 'is_null' | 'is_not_null';
|
||||||
|
|
||||||
|
export type Operation = 'read' | 'create' | 'update' | 'delete';
|
||||||
|
export type SortDirection = 'asc' | 'desc' | 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
export interface Parameter {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
sequence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreloadOption {
|
||||||
|
relation: string;
|
||||||
|
table_name?: string;
|
||||||
|
columns?: string[];
|
||||||
|
omit_columns?: string[];
|
||||||
|
sort?: SortOption[];
|
||||||
|
filters?: FilterOption[];
|
||||||
|
where?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
updatable?: boolean;
|
||||||
|
computed_ql?: Record<string, string>;
|
||||||
|
recursive?: boolean;
|
||||||
|
// Relationship keys
|
||||||
|
primary_key?: string;
|
||||||
|
related_key?: string;
|
||||||
|
foreign_key?: string;
|
||||||
|
recursive_child_key?: string;
|
||||||
|
// Custom SQL JOINs
|
||||||
|
sql_joins?: string[];
|
||||||
|
join_aliases?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
column: string;
|
||||||
|
operator: Operator | string;
|
||||||
|
value: any;
|
||||||
|
logic_operator?: 'AND' | 'OR';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortOption {
|
||||||
|
column: string;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomOperator {
|
||||||
|
name: string;
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputedColumn {
|
||||||
|
name: string;
|
||||||
|
expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
preload?: PreloadOption[];
|
||||||
|
columns?: string[];
|
||||||
|
omit_columns?: string[];
|
||||||
|
filters?: FilterOption[];
|
||||||
|
sort?: SortOption[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
customOperators?: CustomOperator[];
|
||||||
|
computedColumns?: ComputedColumn[];
|
||||||
|
parameters?: Parameter[];
|
||||||
|
cursor_forward?: string;
|
||||||
|
cursor_backward?: string;
|
||||||
|
fetch_row_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
operation: Operation;
|
||||||
|
id?: number | string | string[];
|
||||||
|
data?: any | any[];
|
||||||
|
options?: Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Metadata {
|
||||||
|
total: number;
|
||||||
|
count: number;
|
||||||
|
filtered: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
row_number?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
metadata?: Metadata;
|
||||||
|
error?: APIError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Column {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
is_nullable: boolean;
|
||||||
|
is_primary: boolean;
|
||||||
|
is_unique: boolean;
|
||||||
|
has_index: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableMetadata {
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
columns: Column[];
|
||||||
|
relations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { getMetadata, read, create, update, deleteEntity } from "./api";
|
|
||||||
import { ClientConfig } from "./types";
|
|
||||||
|
|
||||||
// Usage Examples
|
|
||||||
const config: ClientConfig = {
|
|
||||||
baseUrl: 'http://api.example.com/v1',
|
|
||||||
token: 'your-token-here'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Example usage
|
|
||||||
const examples = async () => {
|
|
||||||
// Get metadata
|
|
||||||
const metadata = await getMetadata(config, 'test', 'employees');
|
|
||||||
|
|
||||||
|
|
||||||
// Read with relations
|
|
||||||
const employees = await read(config, 'test', 'employees', undefined, {
|
|
||||||
preload: [
|
|
||||||
{
|
|
||||||
relation: 'department',
|
|
||||||
columns: ['id', 'name']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
column: 'status',
|
|
||||||
operator: 'eq',
|
|
||||||
value: 'active'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create single record
|
|
||||||
const newEmployee = await create(config, 'test', 'employees', {
|
|
||||||
first_name: 'John',
|
|
||||||
last_name: 'Doe',
|
|
||||||
email: 'john@example.com'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bulk create
|
|
||||||
const newEmployees = await create(config, 'test', 'employees', [
|
|
||||||
{
|
|
||||||
first_name: 'Jane',
|
|
||||||
last_name: 'Smith',
|
|
||||||
email: 'jane@example.com'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
first_name: 'Bob',
|
|
||||||
last_name: 'Johnson',
|
|
||||||
email: 'bob@example.com'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update single record
|
|
||||||
const updatedEmployee = await update(config, 'test', 'employees',
|
|
||||||
{ status: 'inactive' },
|
|
||||||
'emp123'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bulk update
|
|
||||||
const updatedEmployees = await update(config, 'test', 'employees',
|
|
||||||
{ department_id: 'dept2' },
|
|
||||||
['emp1', 'emp2', 'emp3']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
await deleteEntity(config, 'test', 'employees', 'emp123');
|
|
||||||
};
|
|
||||||
296
resolvespec-js/src/headerspec/client.ts
Normal file
296
resolvespec-js/src/headerspec/client.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import type {
|
||||||
|
ClientConfig,
|
||||||
|
APIResponse,
|
||||||
|
Options,
|
||||||
|
FilterOption,
|
||||||
|
SortOption,
|
||||||
|
PreloadOption,
|
||||||
|
CustomOperator,
|
||||||
|
} from '../common/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a value with base64 and ZIP_ prefix for complex header values.
|
||||||
|
*/
|
||||||
|
export function encodeHeaderValue(value: string): string {
|
||||||
|
if (typeof btoa === 'function') {
|
||||||
|
return 'ZIP_' + btoa(value);
|
||||||
|
}
|
||||||
|
return 'ZIP_' + Buffer.from(value, 'utf-8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a header value that may be base64 encoded with ZIP_ or __ prefix.
|
||||||
|
*/
|
||||||
|
export function decodeHeaderValue(value: string): string {
|
||||||
|
let code = value;
|
||||||
|
|
||||||
|
if (code.startsWith('ZIP_')) {
|
||||||
|
code = code.slice(4).replace(/[\n\r ]/g, '');
|
||||||
|
code = decodeBase64(code);
|
||||||
|
} else if (code.startsWith('__')) {
|
||||||
|
code = code.slice(2).replace(/[\n\r ]/g, '');
|
||||||
|
code = decodeBase64(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nested encoding
|
||||||
|
if (code.startsWith('ZIP_') || code.startsWith('__')) {
|
||||||
|
code = decodeHeaderValue(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(str: string): string {
|
||||||
|
if (typeof atob === 'function') {
|
||||||
|
return atob(str);
|
||||||
|
}
|
||||||
|
return Buffer.from(str, 'base64').toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTTP headers from Options, matching Go's restheadspec handler conventions.
|
||||||
|
*
|
||||||
|
* Header mapping:
|
||||||
|
* - X-Select-Fields: comma-separated columns
|
||||||
|
* - X-Not-Select-Fields: comma-separated omit_columns
|
||||||
|
* - X-FieldFilter-{col}: exact match (eq)
|
||||||
|
* - X-SearchOp-{operator}-{col}: AND filter
|
||||||
|
* - X-SearchOr-{operator}-{col}: OR filter
|
||||||
|
* - X-Sort: +col (asc), -col (desc)
|
||||||
|
* - X-Limit, X-Offset: pagination
|
||||||
|
* - X-Cursor-Forward, X-Cursor-Backward: cursor pagination
|
||||||
|
* - X-Preload: RelationName:field1,field2 pipe-separated
|
||||||
|
* - X-Fetch-RowNumber: row number fetch
|
||||||
|
* - X-CQL-SEL-{col}: computed columns
|
||||||
|
* - X-Custom-SQL-W: custom operators (AND)
|
||||||
|
*/
|
||||||
|
export function buildHeaders(options: Options): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Column selection
|
||||||
|
if (options.columns?.length) {
|
||||||
|
headers['X-Select-Fields'] = options.columns.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.omit_columns?.length) {
|
||||||
|
headers['X-Not-Select-Fields'] = options.omit_columns.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
if (options.filters?.length) {
|
||||||
|
for (const filter of options.filters) {
|
||||||
|
const logicOp = filter.logic_operator ?? 'AND';
|
||||||
|
const op = mapOperatorToHeaderOp(filter.operator);
|
||||||
|
const valueStr = formatFilterValue(filter);
|
||||||
|
|
||||||
|
if (filter.operator === 'eq' && logicOp === 'AND') {
|
||||||
|
// Simple field filter shorthand
|
||||||
|
headers[`X-FieldFilter-${filter.column}`] = valueStr;
|
||||||
|
} else if (logicOp === 'OR') {
|
||||||
|
headers[`X-SearchOr-${op}-${filter.column}`] = valueStr;
|
||||||
|
} else {
|
||||||
|
headers[`X-SearchOp-${op}-${filter.column}`] = valueStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
if (options.sort?.length) {
|
||||||
|
const sortParts = options.sort.map((s: SortOption) => {
|
||||||
|
const dir = s.direction.toUpperCase();
|
||||||
|
return dir === 'DESC' ? `-${s.column}` : `+${s.column}`;
|
||||||
|
});
|
||||||
|
headers['X-Sort'] = sortParts.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
if (options.limit !== undefined) {
|
||||||
|
headers['X-Limit'] = String(options.limit);
|
||||||
|
}
|
||||||
|
if (options.offset !== undefined) {
|
||||||
|
headers['X-Offset'] = String(options.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor pagination
|
||||||
|
if (options.cursor_forward) {
|
||||||
|
headers['X-Cursor-Forward'] = options.cursor_forward;
|
||||||
|
}
|
||||||
|
if (options.cursor_backward) {
|
||||||
|
headers['X-Cursor-Backward'] = options.cursor_backward;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload
|
||||||
|
if (options.preload?.length) {
|
||||||
|
const parts = options.preload.map((p: PreloadOption) => {
|
||||||
|
if (p.columns?.length) {
|
||||||
|
return `${p.relation}:${p.columns.join(',')}`;
|
||||||
|
}
|
||||||
|
return p.relation;
|
||||||
|
});
|
||||||
|
headers['X-Preload'] = parts.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch row number
|
||||||
|
if (options.fetch_row_number) {
|
||||||
|
headers['X-Fetch-RowNumber'] = options.fetch_row_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed columns
|
||||||
|
if (options.computedColumns?.length) {
|
||||||
|
for (const cc of options.computedColumns) {
|
||||||
|
headers[`X-CQL-SEL-${cc.name}`] = cc.expression;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom operators -> X-Custom-SQL-W
|
||||||
|
if (options.customOperators?.length) {
|
||||||
|
const sqlParts = options.customOperators.map((co: CustomOperator) => co.sql);
|
||||||
|
headers['X-Custom-SQL-W'] = sqlParts.join(' AND ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOperatorToHeaderOp(operator: string): string {
|
||||||
|
switch (operator) {
|
||||||
|
case 'eq': return 'equals';
|
||||||
|
case 'neq': return 'notequals';
|
||||||
|
case 'gt': return 'greaterthan';
|
||||||
|
case 'gte': return 'greaterthanorequal';
|
||||||
|
case 'lt': return 'lessthan';
|
||||||
|
case 'lte': return 'lessthanorequal';
|
||||||
|
case 'like':
|
||||||
|
case 'ilike':
|
||||||
|
case 'contains': return 'contains';
|
||||||
|
case 'startswith': return 'beginswith';
|
||||||
|
case 'endswith': return 'endswith';
|
||||||
|
case 'in': return 'in';
|
||||||
|
case 'between': return 'between';
|
||||||
|
case 'between_inclusive': return 'betweeninclusive';
|
||||||
|
case 'is_null': return 'empty';
|
||||||
|
case 'is_not_null': return 'notempty';
|
||||||
|
default: return operator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFilterValue(filter: FilterOption): string {
|
||||||
|
if (filter.value === null || filter.value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (Array.isArray(filter.value)) {
|
||||||
|
return filter.value.join(',');
|
||||||
|
}
|
||||||
|
return String(filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instances = new Map<string, HeaderSpecClient>();
|
||||||
|
|
||||||
|
export function getHeaderSpecClient(config: ClientConfig): HeaderSpecClient {
|
||||||
|
const key = config.baseUrl;
|
||||||
|
let instance = instances.get(key);
|
||||||
|
if (!instance) {
|
||||||
|
instance = new HeaderSpecClient(config);
|
||||||
|
instances.set(key, instance);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeaderSpec REST client.
|
||||||
|
* Sends query options via HTTP headers instead of request body, matching the Go restheadspec handler.
|
||||||
|
*
|
||||||
|
* HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete
|
||||||
|
*/
|
||||||
|
export class HeaderSpecClient {
|
||||||
|
private config: ClientConfig;
|
||||||
|
|
||||||
|
constructor(config: ClientConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUrl(schema: string, entity: string, id?: string): string {
|
||||||
|
let url = `${this.config.baseUrl}/${schema}/${entity}`;
|
||||||
|
if (id) {
|
||||||
|
url += `/${id}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (this.config.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.config.token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithError<T>(url: string, init: RequestInit): Promise<APIResponse<T>> {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error?.message || 'An error occurred');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read<T = any>(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
id?: string,
|
||||||
|
options?: Options
|
||||||
|
): Promise<APIResponse<T>> {
|
||||||
|
const url = this.buildUrl(schema, entity, id);
|
||||||
|
const optHeaders = options ? buildHeaders(options) : {};
|
||||||
|
return this.fetchWithError<T>(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { ...this.baseHeaders(), ...optHeaders },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create<T = any>(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
data: any,
|
||||||
|
options?: Options
|
||||||
|
): Promise<APIResponse<T>> {
|
||||||
|
const url = this.buildUrl(schema, entity);
|
||||||
|
const optHeaders = options ? buildHeaders(options) : {};
|
||||||
|
return this.fetchWithError<T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...this.baseHeaders(), ...optHeaders },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update<T = any>(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
id: string,
|
||||||
|
data: any,
|
||||||
|
options?: Options
|
||||||
|
): Promise<APIResponse<T>> {
|
||||||
|
const url = this.buildUrl(schema, entity, id);
|
||||||
|
const optHeaders = options ? buildHeaders(options) : {};
|
||||||
|
return this.fetchWithError<T>(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...this.baseHeaders(), ...optHeaders },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
id: string
|
||||||
|
): Promise<APIResponse<void>> {
|
||||||
|
const url = this.buildUrl(schema, entity, id);
|
||||||
|
return this.fetchWithError<void>(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
7
resolvespec-js/src/headerspec/index.ts
Normal file
7
resolvespec-js/src/headerspec/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
HeaderSpecClient,
|
||||||
|
getHeaderSpecClient,
|
||||||
|
buildHeaders,
|
||||||
|
encodeHeaderValue,
|
||||||
|
decodeHeaderValue,
|
||||||
|
} from './client';
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
// Types
|
// Common types
|
||||||
export * from './types';
|
export * from './common';
|
||||||
export * from './websocket-types';
|
|
||||||
|
|
||||||
// WebSocket Client
|
// REST client (ResolveSpec)
|
||||||
export { WebSocketClient } from './websocket-client';
|
export * from './resolvespec';
|
||||||
export type { WebSocketClient as default } from './websocket-client';
|
|
||||||
|
// WebSocket client
|
||||||
|
export * from './websocketspec';
|
||||||
|
|
||||||
|
// HeaderSpec client
|
||||||
|
export * from './headerspec';
|
||||||
|
|||||||
141
resolvespec-js/src/resolvespec/client.ts
Normal file
141
resolvespec-js/src/resolvespec/client.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { ClientConfig, APIResponse, TableMetadata, Options, RequestBody } from '../common/types';
|
||||||
|
|
||||||
|
const instances = new Map<string, ResolveSpecClient>();
|
||||||
|
|
||||||
|
export function getResolveSpecClient(config: ClientConfig): ResolveSpecClient {
|
||||||
|
const key = config.baseUrl;
|
||||||
|
let instance = instances.get(key);
|
||||||
|
if (!instance) {
|
||||||
|
instance = new ResolveSpecClient(config);
|
||||||
|
instances.set(key, instance);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResolveSpecClient {
|
||||||
|
private config: ClientConfig;
|
||||||
|
|
||||||
|
constructor(config: ClientConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUrl(schema: string, entity: string, id?: string): string {
|
||||||
|
let url = `${this.config.baseUrl}/${schema}/${entity}`;
|
||||||
|
if (id) {
|
||||||
|
url += `/${id}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private baseHeaders(): HeadersInit {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.config.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.config.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithError<T>(url: string, options: RequestInit): Promise<APIResponse<T>> {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error?.message || 'An error occurred');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetadata(schema: string, entity: string): Promise<APIResponse<TableMetadata>> {
|
||||||
|
const url = this.buildUrl(schema, entity);
|
||||||
|
return this.fetchWithError<TableMetadata>(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async read<T = any>(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
id?: number | string | string[],
|
||||||
|
options?: Options
|
||||||
|
): Promise<APIResponse<T>> {
|
||||||
|
const urlId = typeof id === 'number' || typeof id === 'string' ? String(id) : undefined;
|
||||||
|
const url = this.buildUrl(schema, entity, urlId);
|
||||||
|
const body: RequestBody = {
|
||||||
|
operation: 'read',
|
||||||
|
id: Array.isArray(id) ? id : undefined,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.fetchWithError<T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create<T = any>(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
data: any | any[],
|
||||||
|
options?: Options
|
||||||
|
): Promise<APIResponse<T>> {
|
||||||
|
const url = this.buildUrl(schema, entity);
|
||||||
|
const body: RequestBody = {
|
||||||
|
operation: 'create',
|
||||||
|
data,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.fetchWithError<T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update<T = any>(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
data: any | any[],
|
||||||
|
id?: number | string | string[],
|
||||||
|
options?: Options
|
||||||
|
): Promise<APIResponse<T>> {
|
||||||
|
const urlId = typeof id === 'number' || typeof id === 'string' ? String(id) : undefined;
|
||||||
|
const url = this.buildUrl(schema, entity, urlId);
|
||||||
|
const body: RequestBody = {
|
||||||
|
operation: 'update',
|
||||||
|
id: Array.isArray(id) ? id : undefined,
|
||||||
|
data,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.fetchWithError<T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(
|
||||||
|
schema: string,
|
||||||
|
entity: string,
|
||||||
|
id: number | string
|
||||||
|
): Promise<APIResponse<void>> {
|
||||||
|
const url = this.buildUrl(schema, entity, String(id));
|
||||||
|
const body: RequestBody = {
|
||||||
|
operation: 'delete',
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.fetchWithError<void>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.baseHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
resolvespec-js/src/resolvespec/index.ts
Normal file
1
resolvespec-js/src/resolvespec/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ResolveSpecClient, getResolveSpecClient } from './client';
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Types
|
|
||||||
export type Operator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'in';
|
|
||||||
export type Operation = 'read' | 'create' | 'update' | 'delete';
|
|
||||||
export type SortDirection = 'asc' | 'desc';
|
|
||||||
|
|
||||||
export interface PreloadOption {
|
|
||||||
relation: string;
|
|
||||||
columns?: string[];
|
|
||||||
filters?: FilterOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilterOption {
|
|
||||||
column: string;
|
|
||||||
operator: Operator;
|
|
||||||
value: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SortOption {
|
|
||||||
column: string;
|
|
||||||
direction: SortDirection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomOperator {
|
|
||||||
name: string;
|
|
||||||
sql: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComputedColumn {
|
|
||||||
name: string;
|
|
||||||
expression: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
preload?: PreloadOption[];
|
|
||||||
columns?: string[];
|
|
||||||
filters?: FilterOption[];
|
|
||||||
sort?: SortOption[];
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
customOperators?: CustomOperator[];
|
|
||||||
computedColumns?: ComputedColumn[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestBody {
|
|
||||||
operation: Operation;
|
|
||||||
id?: string | string[];
|
|
||||||
data?: any | any[];
|
|
||||||
options?: Options;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface APIResponse<T = any> {
|
|
||||||
success: boolean;
|
|
||||||
data: T;
|
|
||||||
metadata?: {
|
|
||||||
total: number;
|
|
||||||
filtered: number;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
};
|
|
||||||
error?: {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
details?: any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Column {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
is_nullable: boolean;
|
|
||||||
is_primary: boolean;
|
|
||||||
is_unique: boolean;
|
|
||||||
has_index: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TableMetadata {
|
|
||||||
schema: string;
|
|
||||||
table: string;
|
|
||||||
columns: Column[];
|
|
||||||
relations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
import { WebSocketClient } from './websocket-client';
|
|
||||||
import type { WSNotificationMessage } from './websocket-types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 1: Basic Usage
|
|
||||||
*/
|
|
||||||
export async function basicUsageExample() {
|
|
||||||
// Create client
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws',
|
|
||||||
reconnect: true,
|
|
||||||
debug: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Read users
|
|
||||||
const users = await client.read('users', {
|
|
||||||
schema: 'public',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'active' }
|
|
||||||
],
|
|
||||||
limit: 10,
|
|
||||||
sort: [
|
|
||||||
{ column: 'name', direction: 'asc' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Users:', users);
|
|
||||||
|
|
||||||
// Create a user
|
|
||||||
const newUser = await client.create('users', {
|
|
||||||
name: 'John Doe',
|
|
||||||
email: 'john@example.com',
|
|
||||||
status: 'active'
|
|
||||||
}, { schema: 'public' });
|
|
||||||
|
|
||||||
console.log('Created user:', newUser);
|
|
||||||
|
|
||||||
// Update user
|
|
||||||
const updatedUser = await client.update('users', '123', {
|
|
||||||
name: 'John Updated'
|
|
||||||
}, { schema: 'public' });
|
|
||||||
|
|
||||||
console.log('Updated user:', updatedUser);
|
|
||||||
|
|
||||||
// Delete user
|
|
||||||
await client.delete('users', '123', { schema: 'public' });
|
|
||||||
|
|
||||||
// Disconnect
|
|
||||||
client.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 2: Real-time Subscriptions
|
|
||||||
*/
|
|
||||||
export async function subscriptionExample() {
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws',
|
|
||||||
debug: true
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Subscribe to user changes
|
|
||||||
const subscriptionId = await client.subscribe(
|
|
||||||
'users',
|
|
||||||
(notification: WSNotificationMessage) => {
|
|
||||||
console.log('User changed:', notification.operation, notification.data);
|
|
||||||
|
|
||||||
switch (notification.operation) {
|
|
||||||
case 'create':
|
|
||||||
console.log('New user created:', notification.data);
|
|
||||||
break;
|
|
||||||
case 'update':
|
|
||||||
console.log('User updated:', notification.data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
console.log('User deleted:', notification.data);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
schema: 'public',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'active' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('Subscribed with ID:', subscriptionId);
|
|
||||||
|
|
||||||
// Later: unsubscribe
|
|
||||||
setTimeout(async () => {
|
|
||||||
await client.unsubscribe(subscriptionId);
|
|
||||||
console.log('Unsubscribed');
|
|
||||||
client.disconnect();
|
|
||||||
}, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 3: Event Handling
|
|
||||||
*/
|
|
||||||
export async function eventHandlingExample() {
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to connection events
|
|
||||||
client.on('connect', () => {
|
|
||||||
console.log('Connected!');
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('disconnect', (event) => {
|
|
||||||
console.log('Disconnected:', event.code, event.reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('stateChange', (state) => {
|
|
||||||
console.log('State changed to:', state);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('message', (message) => {
|
|
||||||
console.log('Received message:', message);
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Your operations here...
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 4: Multiple Subscriptions
|
|
||||||
*/
|
|
||||||
export async function multipleSubscriptionsExample() {
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws',
|
|
||||||
debug: true
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Subscribe to users
|
|
||||||
const userSubId = await client.subscribe(
|
|
||||||
'users',
|
|
||||||
(notification) => {
|
|
||||||
console.log('[Users]', notification.operation, notification.data);
|
|
||||||
},
|
|
||||||
{ schema: 'public' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to posts
|
|
||||||
const postSubId = await client.subscribe(
|
|
||||||
'posts',
|
|
||||||
(notification) => {
|
|
||||||
console.log('[Posts]', notification.operation, notification.data);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
schema: 'public',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'published' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to comments
|
|
||||||
const commentSubId = await client.subscribe(
|
|
||||||
'comments',
|
|
||||||
(notification) => {
|
|
||||||
console.log('[Comments]', notification.operation, notification.data);
|
|
||||||
},
|
|
||||||
{ schema: 'public' }
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('Active subscriptions:', client.getSubscriptions());
|
|
||||||
|
|
||||||
// Clean up after 60 seconds
|
|
||||||
setTimeout(async () => {
|
|
||||||
await client.unsubscribe(userSubId);
|
|
||||||
await client.unsubscribe(postSubId);
|
|
||||||
await client.unsubscribe(commentSubId);
|
|
||||||
client.disconnect();
|
|
||||||
}, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 5: Advanced Queries
|
|
||||||
*/
|
|
||||||
export async function advancedQueriesExample() {
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws'
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Complex query with filters, sorting, pagination, and preloading
|
|
||||||
const posts = await client.read('posts', {
|
|
||||||
schema: 'public',
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'published' },
|
|
||||||
{ column: 'views', operator: 'gte', value: 100 }
|
|
||||||
],
|
|
||||||
columns: ['id', 'title', 'content', 'user_id', 'created_at'],
|
|
||||||
sort: [
|
|
||||||
{ column: 'created_at', direction: 'desc' },
|
|
||||||
{ column: 'views', direction: 'desc' }
|
|
||||||
],
|
|
||||||
preload: [
|
|
||||||
{
|
|
||||||
relation: 'user',
|
|
||||||
columns: ['id', 'name', 'email']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
relation: 'comments',
|
|
||||||
columns: ['id', 'content', 'user_id'],
|
|
||||||
filters: [
|
|
||||||
{ column: 'status', operator: 'eq', value: 'approved' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
limit: 20,
|
|
||||||
offset: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Posts:', posts);
|
|
||||||
|
|
||||||
// Get single record by ID
|
|
||||||
const post = await client.read('posts', {
|
|
||||||
schema: 'public',
|
|
||||||
record_id: '123'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Single post:', post);
|
|
||||||
|
|
||||||
client.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 6: Error Handling
|
|
||||||
*/
|
|
||||||
export async function errorHandlingExample() {
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws',
|
|
||||||
reconnect: true,
|
|
||||||
maxReconnectAttempts: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('Connection error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('stateChange', (state) => {
|
|
||||||
console.log('Connection state:', state);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to read non-existent entity
|
|
||||||
await client.read('nonexistent', { schema: 'public' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Read error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to create invalid record
|
|
||||||
await client.create('users', {
|
|
||||||
// Missing required fields
|
|
||||||
}, { schema: 'public' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Create error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Connection failed:', error);
|
|
||||||
} finally {
|
|
||||||
client.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 7: React Integration
|
|
||||||
*/
|
|
||||||
export function reactIntegrationExample() {
|
|
||||||
const exampleCode = `
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { WebSocketClient } from '@warkypublic/resolvespec-js';
|
|
||||||
|
|
||||||
export function useWebSocket(url: string) {
|
|
||||||
const [client] = useState(() => new WebSocketClient({ url }));
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
client.on('connect', () => setIsConnected(true));
|
|
||||||
client.on('disconnect', () => setIsConnected(false));
|
|
||||||
|
|
||||||
client.connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
client.disconnect();
|
|
||||||
};
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
return { client, isConnected };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsersComponent() {
|
|
||||||
const { client, isConnected } = useWebSocket('ws://localhost:8080/ws');
|
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isConnected) return;
|
|
||||||
|
|
||||||
// Subscribe to user changes
|
|
||||||
const subscribeToUsers = async () => {
|
|
||||||
const subId = await client.subscribe('users', (notification) => {
|
|
||||||
if (notification.operation === 'create') {
|
|
||||||
setUsers(prev => [...prev, notification.data]);
|
|
||||||
} else if (notification.operation === 'update') {
|
|
||||||
setUsers(prev => prev.map(u =>
|
|
||||||
u.id === notification.data.id ? notification.data : u
|
|
||||||
));
|
|
||||||
} else if (notification.operation === 'delete') {
|
|
||||||
setUsers(prev => prev.filter(u => u.id !== notification.data.id));
|
|
||||||
}
|
|
||||||
}, { schema: 'public' });
|
|
||||||
|
|
||||||
// Load initial users
|
|
||||||
const initialUsers = await client.read('users', {
|
|
||||||
schema: 'public',
|
|
||||||
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
|
|
||||||
});
|
|
||||||
setUsers(initialUsers);
|
|
||||||
|
|
||||||
return () => client.unsubscribe(subId);
|
|
||||||
};
|
|
||||||
|
|
||||||
subscribeToUsers();
|
|
||||||
}, [client, isConnected]);
|
|
||||||
|
|
||||||
const createUser = async (name: string, email: string) => {
|
|
||||||
await client.create('users', { name, email, status: 'active' }, {
|
|
||||||
schema: 'public'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Users ({users.length})</h2>
|
|
||||||
{isConnected ? '🟢 Connected' : '🔴 Disconnected'}
|
|
||||||
{/* Render users... */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log(exampleCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 8: TypeScript with Typed Models
|
|
||||||
*/
|
|
||||||
export async function typedModelsExample() {
|
|
||||||
// Define your models
|
|
||||||
interface User {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
status: 'active' | 'inactive';
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Post {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
user_id: number;
|
|
||||||
status: 'draft' | 'published';
|
|
||||||
views: number;
|
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
url: 'ws://localhost:8080/ws'
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
// Type-safe operations
|
|
||||||
const users = await client.read<User[]>('users', {
|
|
||||||
schema: 'public',
|
|
||||||
filters: [{ column: 'status', operator: 'eq', value: 'active' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
const newUser = await client.create<User>('users', {
|
|
||||||
name: 'Alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
status: 'active'
|
|
||||||
}, { schema: 'public' });
|
|
||||||
|
|
||||||
const posts = await client.read<Post[]>('posts', {
|
|
||||||
schema: 'public',
|
|
||||||
preload: [
|
|
||||||
{
|
|
||||||
relation: 'user',
|
|
||||||
columns: ['id', 'name', 'email']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Type-safe subscriptions
|
|
||||||
await client.subscribe(
|
|
||||||
'users',
|
|
||||||
(notification) => {
|
|
||||||
const user = notification.data as User;
|
|
||||||
console.log('User changed:', user.name, user.email);
|
|
||||||
},
|
|
||||||
{ schema: 'public' }
|
|
||||||
);
|
|
||||||
|
|
||||||
client.disconnect();
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,22 @@ import type {
|
|||||||
WSOperation,
|
WSOperation,
|
||||||
WSOptions,
|
WSOptions,
|
||||||
Subscription,
|
Subscription,
|
||||||
SubscriptionOptions,
|
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
WebSocketClientEvents
|
WebSocketClientEvents
|
||||||
} from './websocket-types';
|
} from './types';
|
||||||
|
import type { FilterOption, SortOption, PreloadOption } from '../common/types';
|
||||||
|
|
||||||
|
const instances = new Map<string, WebSocketClient>();
|
||||||
|
|
||||||
|
export function getWebSocketClient(config: WebSocketClientConfig): WebSocketClient {
|
||||||
|
const key = config.url;
|
||||||
|
let instance = instances.get(key);
|
||||||
|
if (!instance) {
|
||||||
|
instance = new WebSocketClient(config);
|
||||||
|
instances.set(key, instance);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
export class WebSocketClient {
|
export class WebSocketClient {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
@@ -36,9 +48,6 @@ export class WebSocketClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to WebSocket server
|
|
||||||
*/
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
this.log('Already connected');
|
this.log('Already connected');
|
||||||
@@ -78,7 +87,6 @@ export class WebSocketClient {
|
|||||||
this.setState('disconnected');
|
this.setState('disconnected');
|
||||||
this.emit('disconnect', event);
|
this.emit('disconnect', event);
|
||||||
|
|
||||||
// Attempt reconnection if enabled and not manually closed
|
|
||||||
if (this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
if (this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`);
|
this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`);
|
||||||
@@ -97,9 +105,6 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from WebSocket server
|
|
||||||
*/
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.isManualClose = true;
|
this.isManualClose = true;
|
||||||
|
|
||||||
@@ -120,9 +125,6 @@ export class WebSocketClient {
|
|||||||
this.messageHandlers.clear();
|
this.messageHandlers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a CRUD request and wait for response
|
|
||||||
*/
|
|
||||||
async request<T = any>(
|
async request<T = any>(
|
||||||
operation: WSOperation,
|
operation: WSOperation,
|
||||||
entity: string,
|
entity: string,
|
||||||
@@ -148,7 +150,6 @@ export class WebSocketClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Set up response handler
|
|
||||||
this.messageHandlers.set(id, (response: WSResponseMessage) => {
|
this.messageHandlers.set(id, (response: WSResponseMessage) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
resolve(response.data);
|
resolve(response.data);
|
||||||
@@ -157,10 +158,8 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send message
|
|
||||||
this.send(message);
|
this.send(message);
|
||||||
|
|
||||||
// Timeout after 30 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.messageHandlers.has(id)) {
|
if (this.messageHandlers.has(id)) {
|
||||||
this.messageHandlers.delete(id);
|
this.messageHandlers.delete(id);
|
||||||
@@ -170,16 +169,13 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read records
|
|
||||||
*/
|
|
||||||
async read<T = any>(entity: string, options?: {
|
async read<T = any>(entity: string, options?: {
|
||||||
schema?: string;
|
schema?: string;
|
||||||
record_id?: string;
|
record_id?: string;
|
||||||
filters?: import('./types').FilterOption[];
|
filters?: FilterOption[];
|
||||||
columns?: string[];
|
columns?: string[];
|
||||||
sort?: import('./types').SortOption[];
|
sort?: SortOption[];
|
||||||
preload?: import('./types').PreloadOption[];
|
preload?: PreloadOption[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
@@ -197,9 +193,6 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a record
|
|
||||||
*/
|
|
||||||
async create<T = any>(entity: string, data: any, options?: {
|
async create<T = any>(entity: string, data: any, options?: {
|
||||||
schema?: string;
|
schema?: string;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
@@ -209,9 +202,6 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a record
|
|
||||||
*/
|
|
||||||
async update<T = any>(entity: string, id: string, data: any, options?: {
|
async update<T = any>(entity: string, id: string, data: any, options?: {
|
||||||
schema?: string;
|
schema?: string;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
@@ -222,9 +212,6 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a record
|
|
||||||
*/
|
|
||||||
async delete(entity: string, id: string, options?: {
|
async delete(entity: string, id: string, options?: {
|
||||||
schema?: string;
|
schema?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -234,9 +221,6 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get metadata for an entity
|
|
||||||
*/
|
|
||||||
async meta<T = any>(entity: string, options?: {
|
async meta<T = any>(entity: string, options?: {
|
||||||
schema?: string;
|
schema?: string;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
@@ -245,15 +229,12 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to entity changes
|
|
||||||
*/
|
|
||||||
async subscribe(
|
async subscribe(
|
||||||
entity: string,
|
entity: string,
|
||||||
callback: (notification: WSNotificationMessage) => void,
|
callback: (notification: WSNotificationMessage) => void,
|
||||||
options?: {
|
options?: {
|
||||||
schema?: string;
|
schema?: string;
|
||||||
filters?: import('./types').FilterOption[];
|
filters?: FilterOption[];
|
||||||
}
|
}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.ensureConnected();
|
this.ensureConnected();
|
||||||
@@ -275,7 +256,6 @@ export class WebSocketClient {
|
|||||||
if (response.success && response.data?.subscription_id) {
|
if (response.success && response.data?.subscription_id) {
|
||||||
const subscriptionId = response.data.subscription_id;
|
const subscriptionId = response.data.subscription_id;
|
||||||
|
|
||||||
// Store subscription
|
|
||||||
this.subscriptions.set(subscriptionId, {
|
this.subscriptions.set(subscriptionId, {
|
||||||
id: subscriptionId,
|
id: subscriptionId,
|
||||||
entity,
|
entity,
|
||||||
@@ -293,7 +273,6 @@ export class WebSocketClient {
|
|||||||
|
|
||||||
this.send(message);
|
this.send(message);
|
||||||
|
|
||||||
// Timeout
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.messageHandlers.has(id)) {
|
if (this.messageHandlers.has(id)) {
|
||||||
this.messageHandlers.delete(id);
|
this.messageHandlers.delete(id);
|
||||||
@@ -303,9 +282,6 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unsubscribe from entity changes
|
|
||||||
*/
|
|
||||||
async unsubscribe(subscriptionId: string): Promise<void> {
|
async unsubscribe(subscriptionId: string): Promise<void> {
|
||||||
this.ensureConnected();
|
this.ensureConnected();
|
||||||
|
|
||||||
@@ -330,7 +306,6 @@ export class WebSocketClient {
|
|||||||
|
|
||||||
this.send(message);
|
this.send(message);
|
||||||
|
|
||||||
// Timeout
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.messageHandlers.has(id)) {
|
if (this.messageHandlers.has(id)) {
|
||||||
this.messageHandlers.delete(id);
|
this.messageHandlers.delete(id);
|
||||||
@@ -340,37 +315,22 @@ export class WebSocketClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of active subscriptions
|
|
||||||
*/
|
|
||||||
getSubscriptions(): Subscription[] {
|
getSubscriptions(): Subscription[] {
|
||||||
return Array.from(this.subscriptions.values());
|
return Array.from(this.subscriptions.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get connection state
|
|
||||||
*/
|
|
||||||
getState(): ConnectionState {
|
getState(): ConnectionState {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if connected
|
|
||||||
*/
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.ws?.readyState === WebSocket.OPEN;
|
return this.ws?.readyState === WebSocket.OPEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add event listener
|
|
||||||
*/
|
|
||||||
on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void {
|
on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void {
|
||||||
this.eventListeners[event] = callback as any;
|
this.eventListeners[event] = callback as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove event listener
|
|
||||||
*/
|
|
||||||
off<K extends keyof WebSocketClientEvents>(event: K): void {
|
off<K extends keyof WebSocketClientEvents>(event: K): void {
|
||||||
delete this.eventListeners[event];
|
delete this.eventListeners[event];
|
||||||
}
|
}
|
||||||
@@ -384,7 +344,6 @@ export class WebSocketClient {
|
|||||||
|
|
||||||
this.emit('message', message);
|
this.emit('message', message);
|
||||||
|
|
||||||
// Handle different message types
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'response':
|
case 'response':
|
||||||
this.handleResponse(message as WSResponseMessage);
|
this.handleResponse(message as WSResponseMessage);
|
||||||
@@ -395,7 +354,6 @@ export class WebSocketClient {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'pong':
|
case 'pong':
|
||||||
// Heartbeat response
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
2
resolvespec-js/src/websocketspec/index.ts
Normal file
2
resolvespec-js/src/websocketspec/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types';
|
||||||
|
export { WebSocketClient, getWebSocketClient } from './client';
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
|
import type { FilterOption, SortOption, PreloadOption, Parameter } from '../common/types';
|
||||||
|
|
||||||
|
// Re-export common types
|
||||||
|
export type { FilterOption, SortOption, PreloadOption, Operator, SortDirection } from '../common/types';
|
||||||
|
|
||||||
// WebSocket Message Types
|
// WebSocket Message Types
|
||||||
export type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
|
export type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
|
||||||
export type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta';
|
export type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta';
|
||||||
|
|
||||||
// Re-export common types
|
|
||||||
export type { FilterOption, SortOption, PreloadOption, Operator, SortDirection } from './types';
|
|
||||||
|
|
||||||
export interface WSOptions {
|
export interface WSOptions {
|
||||||
filters?: import('./types').FilterOption[];
|
filters?: FilterOption[];
|
||||||
columns?: string[];
|
columns?: string[];
|
||||||
preload?: import('./types').PreloadOption[];
|
omit_columns?: string[];
|
||||||
sort?: import('./types').SortOption[];
|
preload?: PreloadOption[];
|
||||||
|
sort?: SortOption[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
parameters?: Parameter[];
|
||||||
|
cursor_forward?: string;
|
||||||
|
cursor_backward?: string;
|
||||||
|
fetch_row_number?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
@@ -78,7 +85,7 @@ export interface WSSubscriptionMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionOptions {
|
export interface SubscriptionOptions {
|
||||||
filters?: import('./types').FilterOption[];
|
filters?: FilterOption[];
|
||||||
onNotification?: (notification: WSNotificationMessage) => void;
|
onNotification?: (notification: WSNotificationMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
21
resolvespec-js/tsconfig.json
Normal file
21
resolvespec-js/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"lib": ["ES2020", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||||
|
}
|
||||||
20
resolvespec-js/vite.config.ts
Normal file
20
resolvespec-js/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import dts from 'vite-plugin-dts';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
dts({ rollupTypes: true }),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
|
name: 'ResolveSpec',
|
||||||
|
formats: ['es', 'cjs'],
|
||||||
|
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['uuid', 'semver'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user