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:
2026-02-15 15:17:39 +02:00
parent fd77385dd6
commit dc85008d7f
30 changed files with 6140 additions and 1350 deletions

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View File

@@ -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),
});
};

View File

@@ -0,0 +1 @@
export * from './types';

View 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;
}

View File

@@ -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');
};

View 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(),
});
}
}

View File

@@ -0,0 +1,7 @@
export {
HeaderSpecClient,
getHeaderSpecClient,
buildHeaders,
encodeHeaderValue,
decodeHeaderValue,
} from './client';

View File

@@ -1,7 +1,11 @@
// Types
export * from './types';
export * from './websocket-types';
// Common types
export * from './common';
// WebSocket Client
export { WebSocketClient } from './websocket-client';
export type { WebSocketClient as default } from './websocket-client';
// REST client (ResolveSpec)
export * from './resolvespec';
// WebSocket client
export * from './websocketspec';
// HeaderSpec client
export * from './headerspec';

View 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),
});
}
}

View File

@@ -0,0 +1 @@
export { ResolveSpecClient, getResolveSpecClient } from './client';

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -8,10 +8,22 @@ import type {
WSOperation,
WSOptions,
Subscription,
SubscriptionOptions,
ConnectionState,
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 {
private ws: WebSocket | null = null;
@@ -36,9 +48,6 @@ export class WebSocketClient {
};
}
/**
* Connect to WebSocket server
*/
async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) {
this.log('Already connected');
@@ -78,7 +87,6 @@ export class WebSocketClient {
this.setState('disconnected');
this.emit('disconnect', event);
// Attempt reconnection if enabled and not manually closed
if (this.config.reconnect && !this.isManualClose && this.reconnectAttempts < this.config.maxReconnectAttempts) {
this.reconnectAttempts++;
this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`);
@@ -97,9 +105,6 @@ export class WebSocketClient {
});
}
/**
* Disconnect from WebSocket server
*/
disconnect(): void {
this.isManualClose = true;
@@ -120,9 +125,6 @@ export class WebSocketClient {
this.messageHandlers.clear();
}
/**
* Send a CRUD request and wait for response
*/
async request<T = any>(
operation: WSOperation,
entity: string,
@@ -148,7 +150,6 @@ export class WebSocketClient {
};
return new Promise((resolve, reject) => {
// Set up response handler
this.messageHandlers.set(id, (response: WSResponseMessage) => {
if (response.success) {
resolve(response.data);
@@ -157,10 +158,8 @@ export class WebSocketClient {
}
});
// Send message
this.send(message);
// Timeout after 30 seconds
setTimeout(() => {
if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id);
@@ -170,16 +169,13 @@ export class WebSocketClient {
});
}
/**
* Read records
*/
async read<T = any>(entity: string, options?: {
schema?: string;
record_id?: string;
filters?: import('./types').FilterOption[];
filters?: FilterOption[];
columns?: string[];
sort?: import('./types').SortOption[];
preload?: import('./types').PreloadOption[];
sort?: SortOption[];
preload?: PreloadOption[];
limit?: number;
offset?: number;
}): Promise<T> {
@@ -197,9 +193,6 @@ export class WebSocketClient {
});
}
/**
* Create a record
*/
async create<T = any>(entity: string, data: any, options?: {
schema?: string;
}): Promise<T> {
@@ -209,9 +202,6 @@ export class WebSocketClient {
});
}
/**
* Update a record
*/
async update<T = any>(entity: string, id: string, data: any, options?: {
schema?: string;
}): Promise<T> {
@@ -222,9 +212,6 @@ export class WebSocketClient {
});
}
/**
* Delete a record
*/
async delete(entity: string, id: string, options?: {
schema?: string;
}): Promise<void> {
@@ -234,9 +221,6 @@ export class WebSocketClient {
});
}
/**
* Get metadata for an entity
*/
async meta<T = any>(entity: string, options?: {
schema?: string;
}): Promise<T> {
@@ -245,15 +229,12 @@ export class WebSocketClient {
});
}
/**
* Subscribe to entity changes
*/
async subscribe(
entity: string,
callback: (notification: WSNotificationMessage) => void,
options?: {
schema?: string;
filters?: import('./types').FilterOption[];
filters?: FilterOption[];
}
): Promise<string> {
this.ensureConnected();
@@ -275,7 +256,6 @@ export class WebSocketClient {
if (response.success && response.data?.subscription_id) {
const subscriptionId = response.data.subscription_id;
// Store subscription
this.subscriptions.set(subscriptionId, {
id: subscriptionId,
entity,
@@ -293,7 +273,6 @@ export class WebSocketClient {
this.send(message);
// Timeout
setTimeout(() => {
if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id);
@@ -303,9 +282,6 @@ export class WebSocketClient {
});
}
/**
* Unsubscribe from entity changes
*/
async unsubscribe(subscriptionId: string): Promise<void> {
this.ensureConnected();
@@ -330,7 +306,6 @@ export class WebSocketClient {
this.send(message);
// Timeout
setTimeout(() => {
if (this.messageHandlers.has(id)) {
this.messageHandlers.delete(id);
@@ -340,37 +315,22 @@ export class WebSocketClient {
});
}
/**
* Get list of active subscriptions
*/
getSubscriptions(): Subscription[] {
return Array.from(this.subscriptions.values());
}
/**
* Get connection state
*/
getState(): ConnectionState {
return this.state;
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
/**
* Add event listener
*/
on<K extends keyof WebSocketClientEvents>(event: K, callback: WebSocketClientEvents[K]): void {
this.eventListeners[event] = callback as any;
}
/**
* Remove event listener
*/
off<K extends keyof WebSocketClientEvents>(event: K): void {
delete this.eventListeners[event];
}
@@ -384,7 +344,6 @@ export class WebSocketClient {
this.emit('message', message);
// Handle different message types
switch (message.type) {
case 'response':
this.handleResponse(message as WSResponseMessage);
@@ -395,7 +354,6 @@ export class WebSocketClient {
break;
case 'pong':
// Heartbeat response
break;
default:

View File

@@ -0,0 +1,2 @@
export * from './types';
export { WebSocketClient, getWebSocketClient } from './client';

View File

@@ -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
export type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong';
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 {
filters?: import('./types').FilterOption[];
filters?: FilterOption[];
columns?: string[];
preload?: import('./types').PreloadOption[];
sort?: import('./types').SortOption[];
omit_columns?: string[];
preload?: PreloadOption[];
sort?: SortOption[];
limit?: number;
offset?: number;
parameters?: Parameter[];
cursor_forward?: string;
cursor_backward?: string;
fetch_row_number?: string;
}
export interface WSMessage {
@@ -78,7 +85,7 @@ export interface WSSubscriptionMessage {
}
export interface SubscriptionOptions {
filters?: import('./types').FilterOption[];
filters?: FilterOption[];
onNotification?: (notification: WSNotificationMessage) => void;
}