Compare commits

...

8 Commits

Author SHA1 Message Date
Hein
e923b0a2a3 feat(routes): add authentication middleware support for routes
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -25m48s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -25m25s
Build , Vet Test, and Lint / Lint Code (push) Successful in -25m29s
Build , Vet Test, and Lint / Build (push) Successful in -25m41s
Tests / Integration Tests (push) Failing after -26m14s
Tests / Unit Tests (push) Successful in -26m3s
2026-02-16 10:38:06 +02:00
ea4a4371ba feat(changesets): add README and config files for changeset management
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -25m56s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -25m29s
Build , Vet Test, and Lint / Build (push) Successful in -25m27s
Build , Vet Test, and Lint / Lint Code (push) Successful in -24m54s
Tests / Integration Tests (push) Failing after -26m14s
Tests / Unit Tests (push) Successful in -25m59s
2026-02-15 19:51:57 +02:00
b3694e50fe refactor(websocket): rename classes and functions for clarity 2026-02-15 19:51:05 +02:00
b76dae5991 refactor(headerspec): improve code formatting and consistency 2026-02-15 19:49:29 +02:00
dc85008d7f 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.
2026-02-15 15:17:39 +02:00
Hein
fd77385dd6 feat(handler): enhance FetchRowNumber support in handlers
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -26m2s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -25m39s
Build , Vet Test, and Lint / Lint Code (push) Successful in -25m42s
Build , Vet Test, and Lint / Build (push) Successful in -25m55s
Tests / Integration Tests (push) Failing after -26m29s
Tests / Unit Tests (push) Successful in -26m17s
* Implement FetchRowNumber handling in multiple handlers
* Improve error logging for missing rows with filters
* Set row numbers correctly based on FetchRowNumber
2026-02-10 17:42:27 +02:00
Hein
b322ef76a2 Merge branch 'main' of https://github.com/bitechdev/ResolveSpec 2026-02-10 16:55:58 +02:00
Hein
a6c7edb0e4 feat(resolvespec): add OR logic support in filters
* Introduce `logic_operator` field to combine filters with OR logic.
* Implement grouping for consecutive OR filters to ensure proper SQL precedence.
* Add support for custom SQL operators in filter conditions.
* Enhance `fetch_row_number` functionality to return specific record with its position.
* Update tests to cover new filter logic and grouping behavior.

Features Implemented:

  1. OR Logic Filter Support (SearchOr)
    - Added to resolvespec, restheadspec, and websocketspec
    - Consecutive OR filters are automatically grouped with parentheses
    - Prevents SQL logic errors: (A OR B OR C) AND D instead of A OR B OR C AND D
  2. CustomOperators
    - Allows arbitrary SQL conditions in resolvespec
    - Properly integrated with filter logic
  3. FetchRowNumber
    - Uses SQL window functions: ROW_NUMBER() OVER (ORDER BY ...)
    - Returns only the specific record (not all records)
    - Available in resolvespec and restheadspec
    - Perfect for "What's my rank?" queries
  4. RowNumber Field Auto-Population
    - Now available in all three packages: resolvespec, restheadspec, and websocketspec
    - Uses simple offset-based math: offset + index + 1
    - Automatically populates RowNumber int64 field if it exists on models
    - Perfect for displaying paginated lists with sequential numbering
2026-02-10 16:55:55 +02:00
41 changed files with 7945 additions and 1463 deletions

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ bin/
test.db
/testserver
tests/data/
node_modules/
resolvespec-js/dist/

View File

@@ -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/).
#### 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
#### WebSocketSpec - WebSocket API

572
pkg/resolvespec/EXAMPLES.md Normal file
View File

@@ -0,0 +1,572 @@
# ResolveSpec Query Features Examples
This document provides examples of using the advanced query features in ResolveSpec, including OR logic filters, Custom Operators, and FetchRowNumber.
## OR Logic in Filters (SearchOr)
### Basic OR Filter Example
Find all users with status "active" OR "pending":
```json
POST /users
{
"operation": "read",
"options": {
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
},
{
"column": "status",
"operator": "eq",
"value": "pending",
"logic_operator": "OR"
}
]
}
}
```
### Combined AND/OR Filters
Find users with (status="active" OR status="pending") AND age >= 18:
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
},
{
"column": "status",
"operator": "eq",
"value": "pending",
"logic_operator": "OR"
},
{
"column": "age",
"operator": "gte",
"value": 18
}
]
}
}
```
**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND age >= 18`
**Important Notes:**
- By default, filters use AND logic
- Consecutive filters with `"logic_operator": "OR"` are automatically grouped with parentheses
- This grouping ensures OR conditions don't interfere with AND conditions
- You don't need to specify `"logic_operator": "AND"` as it's the default
### Multiple OR Groups
You can have multiple separate OR groups:
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
},
{
"column": "status",
"operator": "eq",
"value": "pending",
"logic_operator": "OR"
},
{
"column": "priority",
"operator": "eq",
"value": "high"
},
{
"column": "priority",
"operator": "eq",
"value": "urgent",
"logic_operator": "OR"
}
]
}
}
```
**SQL Generated:** `WHERE (status = 'active' OR status = 'pending') AND (priority = 'high' OR priority = 'urgent')`
## Custom Operators
### Simple Custom SQL Condition
Filter by email domain using custom SQL:
```json
{
"operation": "read",
"options": {
"customOperators": [
{
"name": "company_emails",
"sql": "email LIKE '%@company.com'"
}
]
}
}
```
### Multiple Custom Operators
Combine multiple custom SQL conditions:
```json
{
"operation": "read",
"options": {
"customOperators": [
{
"name": "recent_active",
"sql": "last_login > NOW() - INTERVAL '30 days'"
},
{
"name": "high_score",
"sql": "score > 1000"
}
]
}
}
```
### Complex Custom Operator
Use complex SQL expressions:
```json
{
"operation": "read",
"options": {
"customOperators": [
{
"name": "priority_users",
"sql": "(subscription = 'premium' AND points > 500) OR (subscription = 'enterprise')"
}
]
}
}
```
### Combining Custom Operators with Regular Filters
Mix custom operators with standard filters:
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "country",
"operator": "eq",
"value": "USA"
}
],
"customOperators": [
{
"name": "active_last_month",
"sql": "last_activity > NOW() - INTERVAL '1 month'"
}
]
}
}
```
## Row Numbers
### Two Ways to Get Row Numbers
There are two different features for row numbers:
1. **`fetch_row_number`** - Get the position of ONE specific record in a sorted/filtered set
2. **`RowNumber` field in models** - Automatically number all records in the response
### 1. FetchRowNumber - Get Position of Specific Record
Get the rank/position of a specific user in a leaderboard. **Important:** When `fetch_row_number` is specified, the response contains **ONLY that specific record**, not all records.
```json
{
"operation": "read",
"options": {
"sort": [
{
"column": "score",
"direction": "desc"
}
],
"fetch_row_number": "12345"
}
}
```
**Response - Contains ONLY the specified user:**
```json
{
"success": true,
"data": {
"id": 12345,
"name": "Alice Smith",
"score": 9850,
"level": 42
},
"metadata": {
"total": 10000,
"count": 1,
"filtered": 10000,
"row_number": 42
}
}
```
**Result:** User "12345" is ranked #42 out of 10,000 users. The response includes only Alice's data, not the other 9,999 users.
### Row Number with Filters
Find position within a filtered subset (e.g., "What's my rank in my country?"):
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "country",
"operator": "eq",
"value": "USA"
},
{
"column": "status",
"operator": "eq",
"value": "active"
}
],
"sort": [
{
"column": "score",
"direction": "desc"
}
],
"fetch_row_number": "12345"
}
}
```
**Response:**
```json
{
"success": true,
"data": {
"id": 12345,
"name": "Bob Johnson",
"country": "USA",
"score": 7200,
"status": "active"
},
"metadata": {
"total": 2500,
"count": 1,
"filtered": 2500,
"row_number": 156
}
}
```
**Result:** Bob is ranked #156 out of 2,500 active USA users. Only Bob's record is returned.
### 2. RowNumber Field - Auto-Number All Records
If your model has a `RowNumber int64` field, restheadspec will automatically populate it for paginated results.
**Model Definition:**
```go
type Player struct {
ID int64 `json:"id"`
Name string `json:"name"`
Score int64 `json:"score"`
RowNumber int64 `json:"row_number"` // Will be auto-populated
}
```
**Request (with pagination):**
```json
{
"operation": "read",
"options": {
"sort": [{"column": "score", "direction": "desc"}],
"limit": 10,
"offset": 20
}
}
```
**Response - RowNumber automatically set:**
```json
{
"success": true,
"data": [
{
"id": 456,
"name": "Player21",
"score": 8900,
"row_number": 21
},
{
"id": 789,
"name": "Player22",
"score": 8850,
"row_number": 22
},
{
"id": 123,
"name": "Player23",
"score": 8800,
"row_number": 23
}
// ... records 24-30 ...
]
}
```
**How It Works:**
- `row_number = offset + index + 1` (1-based)
- With offset=20, first record gets row_number=21
- With offset=20, second record gets row_number=22
- Perfect for displaying "Rank" in paginated tables
**Use Case:** Displaying leaderboards with rank numbers:
```
Rank | Player | Score
-----|-----------|-------
21 | Player21 | 8900
22 | Player22 | 8850
23 | Player23 | 8800
```
**Note:** This feature is available in all three packages: resolvespec, restheadspec, and websocketspec.
### When to Use Each Feature
| Feature | Use Case | Returns | Performance |
|---------|----------|---------|-------------|
| `fetch_row_number` | "What's my rank?" | 1 record with position | Fast - 1 record |
| `RowNumber` field | "Show top 10 with ranks" | Many records numbered | Fast - simple math |
**Combined Example - Full Leaderboard UI:**
```javascript
// Request 1: Get current user's rank
const userRank = await api.read({
fetch_row_number: currentUserId,
sort: [{column: "score", direction: "desc"}]
});
// Returns: {id: 123, name: "You", score: 7500, row_number: 156}
// Request 2: Get top 10 with rank numbers
const top10 = await api.read({
sort: [{column: "score", direction: "desc"}],
limit: 10,
offset: 0
});
// Returns: [{row_number: 1, ...}, {row_number: 2, ...}, ...]
// Display:
// "Your Rank: #156"
// "Top Players:"
// "#1 - Alice - 9999"
// "#2 - Bob - 9876"
// ...
```
## Complete Example: Advanced Query
Combine all features for a complex query:
```json
{
"operation": "read",
"options": {
"columns": ["id", "name", "email", "score", "status"],
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
},
{
"column": "status",
"operator": "eq",
"value": "trial",
"logic_operator": "OR"
},
{
"column": "score",
"operator": "gte",
"value": 100
}
],
"customOperators": [
{
"name": "recent_activity",
"sql": "last_login > NOW() - INTERVAL '7 days'"
},
{
"name": "verified_email",
"sql": "email_verified = true"
}
],
"sort": [
{
"column": "score",
"direction": "desc"
},
{
"column": "created_at",
"direction": "asc"
}
],
"fetch_row_number": "12345",
"limit": 50,
"offset": 0
}
}
```
This query:
- Selects specific columns
- Filters for users with status "active" OR "trial"
- AND score >= 100
- Applies custom SQL conditions for recent activity and verified emails
- Sorts by score (descending) then creation date (ascending)
- Returns the row number of user "12345" in this filtered/sorted set
- Returns 50 records starting from the first one
## Use Cases
### 1. Leaderboards - Get Current User's Rank
Get the current user's position and data (returns only their record):
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "game_id",
"operator": "eq",
"value": "game123"
}
],
"sort": [
{
"column": "score",
"direction": "desc"
}
],
"fetch_row_number": "current_user_id"
}
}
```
**Tip:** For full leaderboards, make two requests:
1. One with `fetch_row_number` to get user's rank
2. One with `limit` and `offset` to get top players list
### 2. Multi-Status Search
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "order_status",
"operator": "eq",
"value": "pending"
},
{
"column": "order_status",
"operator": "eq",
"value": "processing",
"logic_operator": "OR"
},
{
"column": "order_status",
"operator": "eq",
"value": "shipped",
"logic_operator": "OR"
}
]
}
}
```
### 3. Advanced Date Filtering
```json
{
"operation": "read",
"options": {
"customOperators": [
{
"name": "this_month",
"sql": "created_at >= DATE_TRUNC('month', CURRENT_DATE)"
},
{
"name": "business_hours",
"sql": "EXTRACT(HOUR FROM created_at) BETWEEN 9 AND 17"
}
]
}
}
```
## Security Considerations
**Warning:** Custom operators allow raw SQL, which can be a security risk if not properly handled:
1. **Never** directly interpolate user input into custom operator SQL
2. Always validate and sanitize custom operator SQL on the backend
3. Consider using a whitelist of allowed custom operators
4. Use prepared statements or parameterized queries when possible
5. Implement proper authorization checks before executing queries
Example of safe custom operator handling in Go:
```go
// Whitelist of allowed custom operators
allowedOperators := map[string]string{
"recent_week": "created_at > NOW() - INTERVAL '7 days'",
"active_users": "status = 'active' AND last_login > NOW() - INTERVAL '30 days'",
"premium_only": "subscription_level = 'premium'",
}
// Validate custom operators from request
for _, op := range req.Options.CustomOperators {
if sql, ok := allowedOperators[op.Name]; ok {
op.SQL = sql // Use whitelisted SQL
} else {
return errors.New("custom operator not allowed: " + op.Name)
}
}
```

View File

@@ -214,6 +214,146 @@ Content-Type: application/json
```json
{
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
},
{
"column": "status",
"operator": "eq",
"value": "pending",
"logic_operator": "OR"
},
{
"column": "age",
"operator": "gte",
"value": 18
}
]
}
```
Produces: `WHERE (status = 'active' OR status = 'pending') AND age >= 18`
This grouping ensures OR conditions don't interfere with other AND conditions in the query.
### Custom Operators
Add custom SQL conditions when needed:
```json
{
"operation": "read",
"options": {
"customOperators": [
{
"name": "email_domain_filter",
"sql": "LOWER(email) LIKE '%@example.com'"
},
{
"name": "recent_records",
"sql": "created_at > NOW() - INTERVAL '7 days'"
}
]
}
}
```
Custom operators are applied as additional WHERE conditions to your query.
### Fetch Row Number
Get the row number (position) of a specific record in the filtered and sorted result set. **When `fetch_row_number` is specified, only that specific record is returned** (not all records).
```json
{
"operation": "read",
"options": {
"filters": [
{
"column": "status",
"operator": "eq",
"value": "active"
}
],
"sort": [
{
"column": "score",
"direction": "desc"
}
],
"fetch_row_number": "12345"
}
}
```
**Response - Returns ONLY the specified record with its position:**
```json
{
"success": true,
"data": {
"id": 12345,
"name": "John Doe",
"score": 850,
"status": "active"
},
"metadata": {
"total": 1000,
"count": 1,
"filtered": 1000,
"row_number": 42
}
}
```
**Use Case:** Perfect for "Show me this user and their ranking" - you get just that one user with their position in the leaderboard.
**Note:** This is different from the `RowNumber` field feature, which automatically numbers all records in a paginated response based on offset. That feature uses simple math (`offset + index + 1`), while `fetch_row_number` uses SQL window functions to calculate the actual position in a sorted/filtered set. To use the `RowNumber` field feature, simply add a `RowNumber int64` field to your model - it will be automatically populated with the row position based on pagination.
## Preloading
Load related entities with custom configuration:
```json
{
"operation": "read",
"options": {
"columns": ["id", "name", "email"],
"preload": [
{
"relation": "posts",
"columns": ["id", "title", "created_at"],
"filters": [
{
"column": "status",
"operator": "eq",
"value": "published"
}
],
"sort": [
{
"column": "created_at",
"direction": "desc"
}
],
"limit": 5
},
{
"relation": "profile",
"columns": ["bio", "website"]
}
]
}
}
```
## Cursor Pagination
Efficient pagination for large datasets:
### First Request (No Cursor)
```json
@@ -427,7 +567,7 @@ Define virtual columns using SQL expressions:
// Check permissions
if !userHasPermission(ctx.Context, ctx.Entity) {
return fmt.Errorf("unauthorized access to %s", ctx.Entity)
return nil
}
// Modify query options
if ctx.Options.Limit == nil || *ctx.Options.Limit > 100 {
@@ -435,17 +575,24 @@ Add custom SQL conditions when needed:
}
return nil
users[i].Email = maskEmail(users[i].Email)
}
})
// Register an after-read hook (e.g., for data transformation)
handler.Hooks().Register(resolvespec.AfterRead, func(ctx *resolvespec.HookContext) error {
})
// Transform or filter results
if users, ok := ctx.Result.([]User); ok {
for i := range users {
users[i].Email = maskEmail(users[i].Email)
}
}
return nil
})
// Register a before-create hook (e.g., for validation)
handler.Hooks().Register(resolvespec.BeforeCreate, func(ctx *resolvespec.HookContext) error {
// Validate data
if user, ok := ctx.Data.(*User); ok {
if user.Email == "" {
return fmt.Errorf("email is required")
}
// Add timestamps

View File

@@ -0,0 +1,143 @@
package resolvespec
import (
"strings"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
// TestBuildFilterCondition tests the filter condition builder
func TestBuildFilterCondition(t *testing.T) {
h := &Handler{}
tests := []struct {
name string
filter common.FilterOption
expectedCondition string
expectedArgsCount int
}{
{
name: "Equal operator",
filter: common.FilterOption{
Column: "status",
Operator: "eq",
Value: "active",
},
expectedCondition: "status = ?",
expectedArgsCount: 1,
},
{
name: "Greater than operator",
filter: common.FilterOption{
Column: "age",
Operator: "gt",
Value: 18,
},
expectedCondition: "age > ?",
expectedArgsCount: 1,
},
{
name: "IN operator",
filter: common.FilterOption{
Column: "status",
Operator: "in",
Value: []string{"active", "pending"},
},
expectedCondition: "status IN (?)",
expectedArgsCount: 1,
},
{
name: "LIKE operator",
filter: common.FilterOption{
Column: "email",
Operator: "like",
Value: "%@example.com",
},
expectedCondition: "email LIKE ?",
expectedArgsCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
condition, args := h.buildFilterCondition(tt.filter)
if condition != tt.expectedCondition {
t.Errorf("Expected condition '%s', got '%s'", tt.expectedCondition, condition)
}
if len(args) != tt.expectedArgsCount {
t.Errorf("Expected %d args, got %d", tt.expectedArgsCount, len(args))
}
// Note: Skip value comparison for slices as they can't be compared with ==
// The important part is that args are populated correctly
})
}
}
// TestORGrouping tests that consecutive OR filters are properly grouped
func TestORGrouping(t *testing.T) {
// This is a conceptual test - in practice we'd need a mock SelectQuery
// to verify the actual SQL grouping behavior
t.Run("Consecutive OR filters should be grouped", func(t *testing.T) {
filters := []common.FilterOption{
{Column: "status", Operator: "eq", Value: "active"},
{Column: "status", Operator: "eq", Value: "pending", LogicOperator: "OR"},
{Column: "status", Operator: "eq", Value: "trial", LogicOperator: "OR"},
{Column: "age", Operator: "gte", Value: 18},
}
// Expected behavior: (status='active' OR status='pending' OR status='trial') AND age>=18
// The first three filters should be grouped together
// The fourth filter should be separate with AND
// Count OR groups
orGroupCount := 0
inORGroup := false
for i := 1; i < len(filters); i++ {
if strings.EqualFold(filters[i].LogicOperator, "OR") && !inORGroup {
orGroupCount++
inORGroup = true
} else if !strings.EqualFold(filters[i].LogicOperator, "OR") {
inORGroup = false
}
}
// We should have detected one OR group
if orGroupCount != 1 {
t.Errorf("Expected 1 OR group, detected %d", orGroupCount)
}
})
t.Run("Multiple OR groups should be handled correctly", func(t *testing.T) {
filters := []common.FilterOption{
{Column: "status", Operator: "eq", Value: "active"},
{Column: "status", Operator: "eq", Value: "pending", LogicOperator: "OR"},
{Column: "priority", Operator: "eq", Value: "high"},
{Column: "priority", Operator: "eq", Value: "urgent", LogicOperator: "OR"},
}
// Expected: (status='active' OR status='pending') AND (priority='high' OR priority='urgent')
// Should have two OR groups
orGroupCount := 0
inORGroup := false
for i := 1; i < len(filters); i++ {
if strings.EqualFold(filters[i].LogicOperator, "OR") && !inORGroup {
orGroupCount++
inORGroup = true
} else if !strings.EqualFold(filters[i].LogicOperator, "OR") {
inORGroup = false
}
}
// We should have detected two OR groups
if orGroupCount != 2 {
t.Errorf("Expected 2 OR groups, detected %d", orGroupCount)
}
})
}

View File

@@ -280,10 +280,13 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
}
// Apply filters
for _, filter := range options.Filters {
logger.Debug("Applying filter: %s %s %v", filter.Column, filter.Operator, filter.Value)
query = h.applyFilter(query, filter)
// Apply filters with proper grouping for OR logic
query = h.applyFilters(query, options.Filters)
// Apply custom operators
for _, customOp := range options.CustomOperators {
logger.Debug("Applying custom operator: %s - %s", customOp.Name, customOp.SQL)
query = query.Where(customOp.SQL)
}
// Apply sorting
@@ -381,7 +384,77 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
}
// Apply pagination
// Handle FetchRowNumber if requested
var rowNumber *int64
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model)
// Build ROW_NUMBER window function SQL
rowNumberSQL := "ROW_NUMBER() OVER ("
if len(options.Sort) > 0 {
rowNumberSQL += "ORDER BY "
for i, sort := range options.Sort {
if i > 0 {
rowNumberSQL += ", "
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
rowNumberSQL += fmt.Sprintf("%s %s", sort.Column, direction)
}
}
rowNumberSQL += ")"
// Create a query to fetch the row number using a subquery approach
// We'll select the PK and row_number, then filter by the target ID
type RowNumResult struct {
RowNum int64 `bun:"row_num"`
}
rowNumQuery := h.db.NewSelect().Table(tableName).
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
Column(pkName)
// Apply the same filters as the main query
for _, filter := range options.Filters {
rowNumQuery = h.applyFilter(rowNumQuery, filter)
}
// Apply custom operators
for _, customOp := range options.CustomOperators {
rowNumQuery = rowNumQuery.Where(customOp.SQL)
}
// Filter for the specific ID we want the row number for
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
// Execute query to get row number
var result RowNumResult
if err := rowNumQuery.Scan(ctx, &result); err != nil {
if err == sql.ErrNoRows {
// Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
if len(options.CustomOperators) > 0 {
customOps := make([]string, 0, len(options.CustomOperators))
for _, op := range options.CustomOperators {
customOps = append(customOps, op.SQL)
}
filterInfo += fmt.Sprintf(", custom operators: [%s]", strings.Join(customOps, "; "))
}
logger.Warn("No row found for primary key %s=%s with %s", pkName, *options.FetchRowNumber, filterInfo)
} else {
logger.Warn("Error fetching row number: %v", err)
}
} else {
rowNumber = &result.RowNum
logger.Debug("Found row number: %d", *rowNumber)
}
}
// Apply pagination (skip if FetchRowNumber is set - we want only that record)
if options.FetchRowNumber == nil || *options.FetchRowNumber == "" {
if options.Limit != nil && *options.Limit > 0 {
logger.Debug("Applying limit: %d", *options.Limit)
query = query.Limit(*options.Limit)
@@ -390,15 +463,26 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("Applying offset: %d", *options.Offset)
query = query.Offset(*options.Offset)
}
}
// Execute query
var result interface{}
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber
var targetID string
if id != "" {
logger.Debug("Querying single record with ID: %s", id)
targetID = id
logger.Debug("Querying single record with URL ID: %s", id)
} else {
targetID = *options.FetchRowNumber
logger.Debug("Querying single record with FetchRowNumber ID: %s", targetID)
}
// For single record, create a new pointer to the struct type
singleResult := reflect.New(modelType).Interface()
pkName := reflection.GetPrimaryKeyName(singleResult)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(singleResult))), id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
@@ -418,20 +502,39 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Info("Successfully retrieved records")
// Build metadata
limit := 0
offset := 0
count := int64(total)
// When FetchRowNumber is used, we only return 1 record
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
count = 1
// Set the fetched row number on the record
if rowNumber != nil {
logger.Debug("FetchRowNumber: Setting row number %d on record", *rowNumber)
h.setRowNumbersOnRecords(result, int(*rowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
}
} else {
if options.Limit != nil {
limit = *options.Limit
}
offset := 0
if options.Offset != nil {
offset = *options.Offset
}
// Set row numbers on records if RowNumber field exists
// Only for multiple records (not when fetching single record)
h.setRowNumbersOnRecords(result, offset)
}
h.sendResponse(w, result, &common.Metadata{
Total: int64(total),
Filtered: int64(total),
Count: count,
Limit: limit,
Offset: offset,
RowNumber: rowNumber,
})
}
@@ -1303,29 +1406,161 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
h.sendResponse(w, recordToDelete, nil)
}
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
// applyFilters applies all filters with proper grouping for OR logic
// Groups consecutive OR filters together to ensure proper query precedence
// Example: [A, B(OR), C(OR), D(AND)] => WHERE (A OR B OR C) AND D
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
if len(filters) == 0 {
return query
}
i := 0
for i < len(filters) {
// Check if this starts an OR group (current or next filter has OR logic)
startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR")
if startORGroup {
// Collect all consecutive filters that are OR'd together
orGroup := []common.FilterOption{filters[i]}
j := i + 1
for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") {
orGroup = append(orGroup, filters[j])
j++
}
// Apply the OR group as a single grouped WHERE clause
query = h.applyFilterGroup(query, orGroup)
i = j
} else {
// Single filter with AND logic (or first filter)
condition, args := h.buildFilterCondition(filters[i])
if condition != "" {
query = query.Where(condition, args...)
}
i++
}
}
return query
}
// applyFilterGroup applies a group of filters that should be OR'd together
// Always wraps them in parentheses and applies as a single WHERE clause
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
if len(filters) == 0 {
return query
}
// Build all conditions and collect args
var conditions []string
var args []interface{}
for _, filter := range filters {
condition, filterArgs := h.buildFilterCondition(filter)
if condition != "" {
conditions = append(conditions, condition)
args = append(args, filterArgs...)
}
}
if len(conditions) == 0 {
return query
}
// Single filter - no need for grouping
if len(conditions) == 1 {
return query.Where(conditions[0], args...)
}
// Multiple conditions - group with parentheses and OR
groupedCondition := "(" + strings.Join(conditions, " OR ") + ")"
return query.Where(groupedCondition, args...)
}
// buildFilterCondition builds a filter condition and returns it with args
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
var condition string
var args []interface{}
switch filter.Operator {
case "eq":
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s = ?", filter.Column)
args = []interface{}{filter.Value}
case "neq":
return query.Where(fmt.Sprintf("%s != ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s != ?", filter.Column)
args = []interface{}{filter.Value}
case "gt":
return query.Where(fmt.Sprintf("%s > ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s > ?", filter.Column)
args = []interface{}{filter.Value}
case "gte":
return query.Where(fmt.Sprintf("%s >= ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s >= ?", filter.Column)
args = []interface{}{filter.Value}
case "lt":
return query.Where(fmt.Sprintf("%s < ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s < ?", filter.Column)
args = []interface{}{filter.Value}
case "lte":
return query.Where(fmt.Sprintf("%s <= ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value}
case "like":
return query.Where(fmt.Sprintf("%s LIKE ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s LIKE ?", filter.Column)
args = []interface{}{filter.Value}
case "ilike":
return query.Where(fmt.Sprintf("%s ILIKE ?", filter.Column), filter.Value)
condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
args = []interface{}{filter.Value}
case "in":
return query.Where(fmt.Sprintf("%s IN (?)", filter.Column), filter.Value)
condition = fmt.Sprintf("%s IN (?)", filter.Column)
args = []interface{}{filter.Value}
default:
return "", nil
}
return condition, args
}
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
// Determine which method to use based on LogicOperator
useOrLogic := strings.EqualFold(filter.LogicOperator, "OR")
var condition string
var args []interface{}
switch filter.Operator {
case "eq":
condition = fmt.Sprintf("%s = ?", filter.Column)
args = []interface{}{filter.Value}
case "neq":
condition = fmt.Sprintf("%s != ?", filter.Column)
args = []interface{}{filter.Value}
case "gt":
condition = fmt.Sprintf("%s > ?", filter.Column)
args = []interface{}{filter.Value}
case "gte":
condition = fmt.Sprintf("%s >= ?", filter.Column)
args = []interface{}{filter.Value}
case "lt":
condition = fmt.Sprintf("%s < ?", filter.Column)
args = []interface{}{filter.Value}
case "lte":
condition = fmt.Sprintf("%s <= ?", filter.Column)
args = []interface{}{filter.Value}
case "like":
condition = fmt.Sprintf("%s LIKE ?", filter.Column)
args = []interface{}{filter.Value}
case "ilike":
condition = fmt.Sprintf("%s ILIKE ?", filter.Column)
args = []interface{}{filter.Value}
case "in":
condition = fmt.Sprintf("%s IN (?)", filter.Column)
args = []interface{}{filter.Value}
default:
return query
}
// Apply filter with appropriate logic operator
if useOrLogic {
return query.WhereOr(condition, args...)
}
return query.Where(condition, args...)
}
// parseTableName splits a table name that may contain schema into separate schema and table
@@ -1709,6 +1944,51 @@ func toSnakeCase(s string) string {
return strings.ToLower(result.String())
}
// setRowNumbersOnRecords sets the RowNumber field on each record if it exists
// The row number is calculated as offset + index + 1 (1-based)
func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) {
// Get the reflect value of the records
recordsValue := reflect.ValueOf(records)
if recordsValue.Kind() == reflect.Ptr {
recordsValue = recordsValue.Elem()
}
// Ensure it's a slice
if recordsValue.Kind() != reflect.Slice {
logger.Debug("setRowNumbersOnRecords: records is not a slice, skipping")
return
}
// Iterate through each record
for i := 0; i < recordsValue.Len(); i++ {
record := recordsValue.Index(i)
// Dereference if it's a pointer
if record.Kind() == reflect.Ptr {
if record.IsNil() {
continue
}
record = record.Elem()
}
// Ensure it's a struct
if record.Kind() != reflect.Struct {
continue
}
// Try to find and set the RowNumber field
rowNumberField := record.FieldByName("RowNumber")
if rowNumberField.IsValid() && rowNumberField.CanSet() {
// Check if the field is of type int64
if rowNumberField.Kind() == reflect.Int64 {
rowNum := int64(offset + i + 1)
rowNumberField.SetInt(rowNum)
logger.Debug("Set RowNumber=%d for record index %d", rowNum, i)
}
}
}
}
// HandleOpenAPI generates and returns the OpenAPI specification
func (h *Handler) HandleOpenAPI(w common.ResponseWriter, r common.Request) {
if h.openAPIGenerator == nil {

View File

@@ -216,9 +216,30 @@ type BunRouterHandler interface {
Handle(method, path string, handler bunrouter.HandlerFunc)
}
// wrapBunRouterHandler wraps a bunrouter handler with auth middleware if provided
func wrapBunRouterHandler(handler bunrouter.HandlerFunc, authMiddleware MiddlewareFunc) bunrouter.HandlerFunc {
if authMiddleware == nil {
return handler
}
return func(w http.ResponseWriter, req bunrouter.Request) error {
// Create an http.Handler that calls the bunrouter handler
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = handler(w, req)
})
// Wrap with auth middleware and execute
wrappedHandler := authMiddleware(httpHandler)
wrappedHandler.ServeHTTP(w, req.Request)
return nil
}
}
// SetupBunRouterRoutes sets up bunrouter routes for the ResolveSpec API
// Accepts bunrouter.Router or bunrouter.Group
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// authMiddleware is optional - if provided, routes will be protected with the middleware
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler, authMiddleware MiddlewareFunc) {
// CORS config
corsConfig := common.DefaultCORSConfig()
@@ -256,7 +277,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
currentEntity := entity
// POST route without ID
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
postEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -267,10 +288,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("POST", entityPath, wrapBunRouterHandler(postEntityHandler, authMiddleware))
// POST route with ID
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
postEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -282,10 +304,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("POST", entityWithIDPath, wrapBunRouterHandler(postEntityWithIDHandler, authMiddleware))
// GET route without ID
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
getEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -296,10 +319,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("GET", entityPath, wrapBunRouterHandler(getEntityHandler, authMiddleware))
// GET route with ID
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
getEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -311,9 +335,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("GET", entityWithIDPath, wrapBunRouterHandler(getEntityWithIDHandler, authMiddleware))
// OPTIONS route without ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
@@ -330,6 +356,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
})
// OPTIONS route with ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
@@ -355,8 +382,8 @@ func ExampleWithBunRouter(bunDB *bun.DB) {
// Create bunrouter
bunRouter := bunrouter.New()
// Setup ResolveSpec routes with bunrouter
SetupBunRouterRoutes(bunRouter, handler)
// Setup ResolveSpec routes with bunrouter without authentication
SetupBunRouterRoutes(bunRouter, handler, nil)
// Start server
// http.ListenAndServe(":8080", bunRouter)
@@ -377,8 +404,8 @@ func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
// Create bunrouter
bunRouter := bunrouter.New()
// Setup ResolveSpec routes
SetupBunRouterRoutes(bunRouter, handler)
// Setup ResolveSpec routes without authentication
SetupBunRouterRoutes(bunRouter, handler, nil)
// This gives you the full uptrace stack: bunrouter + Bun ORM
// http.ListenAndServe(":8080", bunRouter)
@@ -396,8 +423,87 @@ func ExampleBunRouterWithGroup(bunDB *bun.DB) {
apiGroup := bunRouter.NewGroup("/api")
// Setup ResolveSpec routes on the group - routes will be under /api
SetupBunRouterRoutes(apiGroup, handler)
SetupBunRouterRoutes(apiGroup, handler, nil)
// Start server
// http.ListenAndServe(":8080", bunRouter)
}
// ExampleWithGORMAndAuth shows how to use ResolveSpec with GORM and authentication
func ExampleWithGORMAndAuth(db *gorm.DB) {
// Create handler using GORM
_ = NewHandlerWithGORM(db)
// Create auth middleware
// import "github.com/bitechdev/ResolveSpec/pkg/security"
// secList := security.NewSecurityList(myProvider)
// authMiddleware := func(h http.Handler) http.Handler {
// return security.NewAuthHandler(secList, h)
// }
// Setup router with authentication
_ = mux.NewRouter()
// SetupMuxRoutes(muxRouter, handler, authMiddleware)
// Register models
// handler.RegisterModel("public", "users", &User{})
// Start server
// http.ListenAndServe(":8080", muxRouter)
}
// ExampleWithBunAndAuth shows how to use ResolveSpec with Bun and authentication
func ExampleWithBunAndAuth(bunDB *bun.DB) {
// Create Bun adapter
dbAdapter := database.NewBunAdapter(bunDB)
// Create model registry
registry := modelregistry.NewModelRegistry()
// registry.RegisterModel("public.users", &User{})
// Create handler
_ = NewHandler(dbAdapter, registry)
// Create auth middleware
// import "github.com/bitechdev/ResolveSpec/pkg/security"
// secList := security.NewSecurityList(myProvider)
// authMiddleware := func(h http.Handler) http.Handler {
// return security.NewAuthHandler(secList, h)
// }
// Setup routes with authentication
_ = mux.NewRouter()
// SetupMuxRoutes(muxRouter, handler, authMiddleware)
// Start server
// http.ListenAndServe(":8080", muxRouter)
}
// ExampleBunRouterWithBunDBAndAuth shows the full uptrace stack with authentication
func ExampleBunRouterWithBunDBAndAuth(bunDB *bun.DB) {
// Create Bun database adapter
dbAdapter := database.NewBunAdapter(bunDB)
// Create model registry
registry := modelregistry.NewModelRegistry()
// registry.RegisterModel("public.users", &User{})
// Create handler with Bun
_ = NewHandler(dbAdapter, registry)
// Create auth middleware
// import "github.com/bitechdev/ResolveSpec/pkg/security"
// secList := security.NewSecurityList(myProvider)
// authMiddleware := func(h http.Handler) http.Handler {
// return security.NewAuthHandler(secList, h)
// }
// Create bunrouter
_ = bunrouter.New()
// Setup ResolveSpec routes with authentication
// SetupBunRouterRoutes(bunRouter, handler, authMiddleware)
// This gives you the full uptrace stack: bunrouter + Bun ORM with authentication
// http.ListenAndServe(":8080", bunRouter)
}

View File

@@ -549,8 +549,30 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
}
// If ID is provided, filter by ID
if id != "" {
// Handle FetchRowNumber before applying ID filter
// This must happen before the query to get the row position, then filter by PK
var fetchedRowNumber *int64
var fetchRowNumberPKValue string
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(model)
fetchRowNumberPKValue = *options.FetchRowNumber
logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, fetchRowNumberPKValue, options, model)
if err != nil {
logger.Error("Failed to fetch row number: %v", err)
h.sendError(w, http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number", err)
return
}
fetchedRowNumber = &rowNum
logger.Debug("FetchRowNumber: Row number %d for PK %s = %s", rowNum, pkName, fetchRowNumberPKValue)
// Now filter the main query to this specific primary key
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), fetchRowNumberPKValue)
} else if id != "" {
// If ID is provided (and not FetchRowNumber), filter by ID
pkName := reflection.GetPrimaryKeyName(model)
logger.Debug("Filtering by ID=%s: %s", pkName, id)
@@ -730,7 +752,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
// Set row numbers on each record if the model has a RowNumber field
// If FetchRowNumber was used, set the fetched row number instead of offset-based
if fetchedRowNumber != nil {
// FetchRowNumber: set the actual row position on the record
logger.Debug("FetchRowNumber: Setting row number %d on record", *fetchedRowNumber)
h.setRowNumbersOnRecords(modelPtr, int(*fetchedRowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
} else {
h.setRowNumbersOnRecords(modelPtr, offset)
}
metadata := &common.Metadata{
Total: int64(total),
@@ -740,21 +769,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
Offset: offset,
}
// Fetch row number for a specific record if requested
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(model)
pkValue := *options.FetchRowNumber
logger.Debug("Fetching row number for specific PK %s = %s", pkName, pkValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, pkValue, options, model)
if err != nil {
logger.Warn("Failed to fetch row number: %v", err)
// Don't fail the entire request, just log the warning
} else {
metadata.RowNumber = &rowNum
logger.Debug("Row number for PK %s: %d", pkValue, rowNum)
}
// If FetchRowNumber was used, also set it in metadata
if fetchedRowNumber != nil {
metadata.RowNumber = fetchedRowNumber
logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber)
}
// Execute AfterRead hooks
@@ -2602,21 +2620,8 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
sortSQL = fmt.Sprintf("%s.%s ASC", tableName, pkName)
}
// Build WHERE clauses from filters
whereClauses := make([]string, 0)
for i := range options.Filters {
filter := &options.Filters[i]
whereClause := h.buildFilterSQL(filter, tableName)
if whereClause != "" {
whereClauses = append(whereClauses, fmt.Sprintf("(%s)", whereClause))
}
}
// Combine WHERE clauses
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
}
// Build WHERE clause from filters with proper OR grouping
whereSQL := h.buildWhereClauseWithORGrouping(options.Filters, tableName)
// Add custom SQL WHERE if provided
if options.CustomSQLWhere != "" {
@@ -2664,19 +2669,86 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
var result []struct {
RN int64 `bun:"rn"`
}
logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query")
err := h.db.Query(ctx, &result, queryStr, pkValue)
logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err)
if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err)
}
if len(result) == 0 {
return 0, fmt.Errorf("no row found for primary key %s", pkValue)
whereInfo := "none"
if whereSQL != "" {
whereInfo = whereSQL
}
return 0, fmt.Errorf("no row found for primary key %s=%s with active filters: %s", pkName, pkValue, whereInfo)
}
return result[0].RN, nil
}
// buildFilterSQL converts a filter to SQL WHERE clause string
// buildWhereClauseWithORGrouping builds a WHERE clause from filters with proper OR grouping
// Groups consecutive OR filters together to ensure proper SQL precedence
// Example: [A, B(OR), C(OR), D(AND)] => WHERE (A OR B OR C) AND D
func (h *Handler) buildWhereClauseWithORGrouping(filters []common.FilterOption, tableName string) string {
if len(filters) == 0 {
return ""
}
var groups []string
i := 0
for i < len(filters) {
// Check if this starts an OR group (next filter has OR logic)
startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR")
if startORGroup {
// Collect all consecutive filters that are OR'd together
orGroup := []string{}
// Add current filter
filterSQL := h.buildFilterSQL(&filters[i], tableName)
if filterSQL != "" {
orGroup = append(orGroup, filterSQL)
}
// Collect remaining OR filters
j := i + 1
for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") {
filterSQL := h.buildFilterSQL(&filters[j], tableName)
if filterSQL != "" {
orGroup = append(orGroup, filterSQL)
}
j++
}
// Group OR filters with parentheses
if len(orGroup) > 0 {
if len(orGroup) == 1 {
groups = append(groups, orGroup[0])
} else {
groups = append(groups, "("+strings.Join(orGroup, " OR ")+")")
}
}
i = j
} else {
// Single filter with AND logic (or first filter)
filterSQL := h.buildFilterSQL(&filters[i], tableName)
if filterSQL != "" {
groups = append(groups, filterSQL)
}
i++
}
}
if len(groups) == 0 {
return ""
}
return "WHERE " + strings.Join(groups, " AND ")
}
func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string) string {
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName)

View File

@@ -280,9 +280,30 @@ type BunRouterHandler interface {
Handle(method, path string, handler bunrouter.HandlerFunc)
}
// wrapBunRouterHandler wraps a bunrouter handler with auth middleware if provided
func wrapBunRouterHandler(handler bunrouter.HandlerFunc, authMiddleware MiddlewareFunc) bunrouter.HandlerFunc {
if authMiddleware == nil {
return handler
}
return func(w http.ResponseWriter, req bunrouter.Request) error {
// Create an http.Handler that calls the bunrouter handler
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = handler(w, req)
})
// Wrap with auth middleware and execute
wrappedHandler := authMiddleware(httpHandler)
wrappedHandler.ServeHTTP(w, req.Request)
return nil
}
}
// SetupBunRouterRoutes sets up bunrouter routes for the RestHeadSpec API
// Accepts bunrouter.Router or bunrouter.Group
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// authMiddleware is optional - if provided, routes will be protected with the middleware
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler, authMiddleware MiddlewareFunc) {
// CORS config
corsConfig := common.DefaultCORSConfig()
@@ -313,7 +334,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
currentEntity := entity
// GET and POST for /{schema}/{entity}
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
getEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -324,9 +345,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("GET", entityPath, wrapBunRouterHandler(getEntityHandler, authMiddleware))
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
postEntityHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -337,10 +359,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("POST", entityPath, wrapBunRouterHandler(postEntityHandler, authMiddleware))
// GET, POST, PUT, PATCH, DELETE for /{schema}/{entity}/:id
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
getEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -352,9 +375,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("GET", entityWithIDPath, wrapBunRouterHandler(getEntityWithIDHandler, authMiddleware))
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
postEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -366,9 +390,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("POST", entityWithIDPath, wrapBunRouterHandler(postEntityWithIDHandler, authMiddleware))
r.Handle("PUT", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
putEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -380,9 +405,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("PUT", entityWithIDPath, wrapBunRouterHandler(putEntityWithIDHandler, authMiddleware))
r.Handle("PATCH", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
patchEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -394,9 +420,10 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("PATCH", entityWithIDPath, wrapBunRouterHandler(patchEntityWithIDHandler, authMiddleware))
r.Handle("DELETE", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
deleteEntityWithIDHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -408,10 +435,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("DELETE", entityWithIDPath, wrapBunRouterHandler(deleteEntityWithIDHandler, authMiddleware))
// Metadata endpoint
r.Handle("GET", metadataPath, func(w http.ResponseWriter, req bunrouter.Request) error {
metadataHandler := func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
@@ -422,9 +450,11 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
}
r.Handle("GET", metadataPath, wrapBunRouterHandler(metadataHandler, authMiddleware))
// OPTIONS route without ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
@@ -441,6 +471,7 @@ func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
})
// OPTIONS route with ID (returns metadata)
// Don't apply auth middleware to OPTIONS - CORS preflight must not require auth
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
@@ -466,8 +497,8 @@ func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
// Create bunrouter
bunRouter := bunrouter.New()
// Setup routes
SetupBunRouterRoutes(bunRouter, handler)
// Setup routes without authentication
SetupBunRouterRoutes(bunRouter, handler, nil)
// Start server
if err := http.ListenAndServe(":8080", bunRouter); err != nil {
@@ -487,7 +518,7 @@ func ExampleBunRouterWithGroup(bunDB *bun.DB) {
apiGroup := bunRouter.NewGroup("/api")
// Setup RestHeadSpec routes on the group - routes will be under /api
SetupBunRouterRoutes(apiGroup, handler)
SetupBunRouterRoutes(apiGroup, handler, nil)
// Start server
if err := http.ListenAndServe(":8080", bunRouter); err != nil {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"reflect"
"strings"
"time"
"github.com/google/uuid"
@@ -209,10 +210,14 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex
var metadata map[string]interface{}
var err error
if hookCtx.ID != "" {
// Read single record by ID
// Check if FetchRowNumber is specified (treat as single record read)
isFetchRowNumber := hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != ""
if hookCtx.ID != "" || isFetchRowNumber {
// Read single record by ID or FetchRowNumber
data, err = h.readByID(hookCtx)
metadata = map[string]interface{}{"total": 1}
// The row number is already set on the record itself via setRowNumbersOnRecords
} else {
// Read multiple records
data, metadata, err = h.readMultiple(hookCtx)
@@ -509,10 +514,29 @@ func (h *Handler) notifySubscribers(schema, entity string, operation OperationTy
// CRUD operation implementations
func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
// Handle FetchRowNumber before building query
var fetchedRowNumber *int64
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
if hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != "" {
fetchRowNumberPKValue := *hookCtx.Options.FetchRowNumber
logger.Debug("[WebSocketSpec] FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(hookCtx.Context, hookCtx.TableName, pkName, fetchRowNumberPKValue, hookCtx.Options, hookCtx.Model)
if err != nil {
return nil, fmt.Errorf("failed to fetch row number: %w", err)
}
fetchedRowNumber = &rowNum
logger.Debug("[WebSocketSpec] FetchRowNumber: Row number %d for PK %s = %s", rowNum, pkName, fetchRowNumberPKValue)
// Override ID with FetchRowNumber value
hookCtx.ID = fetchRowNumberPKValue
}
query := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
// Add ID filter
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
// Apply columns
@@ -532,6 +556,12 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
return nil, fmt.Errorf("failed to read record: %w", err)
}
// Set the fetched row number on the record if FetchRowNumber was used
if fetchedRowNumber != nil {
logger.Debug("[WebSocketSpec] FetchRowNumber: Setting row number %d on record", *fetchedRowNumber)
h.setRowNumbersOnRecords(hookCtx.ModelPtr, int(*fetchedRowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
}
return hookCtx.ModelPtr, nil
}
@@ -540,10 +570,8 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
// Apply options (simplified implementation)
if hookCtx.Options != nil {
// Apply filters
for _, filter := range hookCtx.Options.Filters {
query = query.Where(fmt.Sprintf("%s %s ?", filter.Column, h.getOperatorSQL(filter.Operator)), filter.Value)
}
// Apply filters with OR grouping support
query = h.applyFilters(query, hookCtx.Options.Filters)
// Apply sorting
for _, sort := range hookCtx.Options.Sort {
@@ -578,6 +606,13 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
return nil, nil, fmt.Errorf("failed to read records: %w", err)
}
// Set row numbers on records if RowNumber field exists
offset := 0
if hookCtx.Options != nil && hookCtx.Options.Offset != nil {
offset = *hookCtx.Options.Offset
}
h.setRowNumbersOnRecords(hookCtx.ModelPtr, offset)
// Get count
metadata = make(map[string]interface{})
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
@@ -683,6 +718,133 @@ func (h *Handler) getMetadata(schema, entity string, model interface{}) map[stri
}
// getOperatorSQL converts filter operator to SQL operator
// applyFilters applies all filters with proper grouping for OR logic
// Groups consecutive OR filters together to ensure proper query precedence
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
if len(filters) == 0 {
return query
}
i := 0
for i < len(filters) {
// Check if this starts an OR group (next filter has OR logic)
startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR")
if startORGroup {
// Collect all consecutive filters that are OR'd together
orGroup := []common.FilterOption{filters[i]}
j := i + 1
for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") {
orGroup = append(orGroup, filters[j])
j++
}
// Apply the OR group as a single grouped WHERE clause
query = h.applyFilterGroup(query, orGroup)
i = j
} else {
// Single filter with AND logic (or first filter)
condition, args := h.buildFilterCondition(filters[i])
if condition != "" {
query = query.Where(condition, args...)
}
i++
}
}
return query
}
// applyFilterGroup applies a group of filters that should be OR'd together
// Always wraps them in parentheses and applies as a single WHERE clause
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
if len(filters) == 0 {
return query
}
// Build all conditions and collect args
var conditions []string
var args []interface{}
for _, filter := range filters {
condition, filterArgs := h.buildFilterCondition(filter)
if condition != "" {
conditions = append(conditions, condition)
args = append(args, filterArgs...)
}
}
if len(conditions) == 0 {
return query
}
// Single filter - no need for grouping
if len(conditions) == 1 {
return query.Where(conditions[0], args...)
}
// Multiple conditions - group with parentheses and OR
groupedCondition := "(" + strings.Join(conditions, " OR ") + ")"
return query.Where(groupedCondition, args...)
}
// buildFilterCondition builds a filter condition and returns it with args
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
var condition string
var args []interface{}
operatorSQL := h.getOperatorSQL(filter.Operator)
condition = fmt.Sprintf("%s %s ?", filter.Column, operatorSQL)
args = []interface{}{filter.Value}
return condition, args
}
// setRowNumbersOnRecords sets the RowNumber field on each record if it exists
// The row number is calculated as offset + index + 1 (1-based)
func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) {
// Get the reflect value of the records
recordsValue := reflect.ValueOf(records)
if recordsValue.Kind() == reflect.Ptr {
recordsValue = recordsValue.Elem()
}
// Ensure it's a slice
if recordsValue.Kind() != reflect.Slice {
logger.Debug("[WebSocketSpec] setRowNumbersOnRecords: records is not a slice, skipping")
return
}
// Iterate through each record
for i := 0; i < recordsValue.Len(); i++ {
record := recordsValue.Index(i)
// Dereference if it's a pointer
if record.Kind() == reflect.Ptr {
if record.IsNil() {
continue
}
record = record.Elem()
}
// Ensure it's a struct
if record.Kind() != reflect.Struct {
continue
}
// Try to find and set the RowNumber field
rowNumberField := record.FieldByName("RowNumber")
if rowNumberField.IsValid() && rowNumberField.CanSet() {
// Check if the field is of type int64
if rowNumberField.Kind() == reflect.Int64 {
rowNum := int64(offset + i + 1)
rowNumberField.SetInt(rowNum)
logger.Debug("[WebSocketSpec] Set RowNumber=%d for record index %d", rowNum, i)
}
}
}
}
func (h *Handler) getOperatorSQL(operator string) string {
switch operator {
case "eq":
@@ -708,6 +870,92 @@ func (h *Handler) getOperatorSQL(operator string) string {
}
}
// FetchRowNumber calculates the row number of a specific record based on sorting and filtering
// Returns the 1-based row number of the record with the given primary key value
func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName string, pkValue string, options *common.RequestOptions, model interface{}) (int64, error) {
defer func() {
if r := recover(); r != nil {
logger.Error("[WebSocketSpec] Panic during FetchRowNumber: %v", r)
}
}()
// Build the sort order SQL
sortSQL := ""
if options != nil && len(options.Sort) > 0 {
sortParts := make([]string, 0, len(options.Sort))
for _, sort := range options.Sort {
if sort.Column == "" {
continue
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
sortParts = append(sortParts, fmt.Sprintf("%s %s", sort.Column, direction))
}
sortSQL = strings.Join(sortParts, ", ")
} else {
// Default sort by primary key
sortSQL = fmt.Sprintf("%s ASC", pkName)
}
// Build WHERE clause from filters
whereSQL := ""
var whereArgs []interface{}
if options != nil && len(options.Filters) > 0 {
var conditions []string
for _, filter := range options.Filters {
operatorSQL := h.getOperatorSQL(filter.Operator)
conditions = append(conditions, fmt.Sprintf("%s.%s %s ?", tableName, filter.Column, operatorSQL))
whereArgs = append(whereArgs, filter.Value)
}
if len(conditions) > 0 {
whereSQL = "WHERE " + strings.Join(conditions, " AND ")
}
}
// Build the final query with parameterized PK value
queryStr := fmt.Sprintf(`
SELECT search.rn
FROM (
SELECT %[1]s.%[2]s,
ROW_NUMBER() OVER(ORDER BY %[3]s) AS rn
FROM %[1]s
%[4]s
) search
WHERE search.%[2]s = ?
`,
tableName, // [1] - table name
pkName, // [2] - primary key column name
sortSQL, // [3] - sort order SQL
whereSQL, // [4] - WHERE clause
)
logger.Debug("[WebSocketSpec] FetchRowNumber query: %s, pkValue: %s", queryStr, pkValue)
// Append PK value to whereArgs
whereArgs = append(whereArgs, pkValue)
// Execute the raw query with parameterized PK value
var result []struct {
RN int64 `bun:"rn"`
}
err := h.db.Query(ctx, &result, queryStr, whereArgs...)
if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err)
}
if len(result) == 0 {
whereInfo := "none"
if whereSQL != "" {
whereInfo = whereSQL
}
return 0, fmt.Errorf("no row found for primary key %s=%s with active filters: %s", pkName, pkValue, whereInfo)
}
return result[0].RN, nil
}
// Shutdown gracefully shuts down the handler
func (h *Handler) Shutdown() {
h.connManager.Shutdown()

View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -0,0 +1,7 @@
# @warkypublic/resolvespec-js
## 1.0.1
### Patch Changes
- Fixed headerpsec

132
resolvespec-js/PLAN.md Normal file
View 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
View 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

View File

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

File diff suppressed because one or more lines are too long

366
resolvespec-js/dist/index.d.ts vendored Normal file
View 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 { }

469
resolvespec-js/dist/index.js vendored Normal file
View File

@@ -0,0 +1,469 @@
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 p(n), f.set(e, t)), t;
}
class p {
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 v(n) {
return typeof btoa == "function" ? "ZIP_" + btoa(n) : "ZIP_" + Buffer.from(n, "utf-8").toString("base64");
}
function w(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 = w(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 = y(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 y(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 C(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 || `${s.statusText} (${s.status})`
);
return {
data: r,
success: !0,
error: r.error ? r.error : void 0,
metadata: {
count: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
total: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
filtered: s.headers.get("content-range") ? Number(s.headers.get("content-range")?.split("/")[1]) : 0,
offset: s.headers.get("content-range") ? Number(
s.headers.get("content-range")?.split("/")[0].split("-")[0]
) : 0,
limit: s.headers.get("x-limit") ? Number(s.headers.get("x-limit")) : 0
}
};
}
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,
p as WebSocketClient,
u as buildHeaders,
w as decodeHeaderValue,
v as encodeHeaderValue,
C as getHeaderSpecClient,
E as getResolveSpecClient,
_ as getWebSocketClient
};

View File

@@ -1,20 +1,23 @@
{
"name": "@warkypublic/resolvespec-js",
"version": "1.0.0",
"description": "Client side library for the ResolveSpec API",
"version": "1.0.1",
"description": "TypeScript client library for ResolveSpec REST, HeaderSpec, and WebSocket APIs",
"type": "module",
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"publishConfig": {
"access": "public",
"main": "./dist/index.js",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"bin",
"README.md"
],
"scripts": {
@@ -25,38 +28,33 @@
"lint": "eslint src"
},
"keywords": [
"string",
"blob",
"dependencies",
"workspace",
"package",
"cli",
"tools",
"npm",
"yarn",
"pnpm"
"resolvespec",
"headerspec",
"websocket",
"rest-client",
"typescript",
"api-client"
],
"author": "Hein (Warkanum) Puth",
"license": "MIT",
"dependencies": {
"semver": "^7.6.3",
"uuid": "^11.0.3"
"uuid": "^13.0.0"
},
"devDependencies": {
"@changesets/cli": "^2.27.10",
"@eslint/js": "^9.16.0",
"@types/jsdom": "^21.1.7",
"eslint": "^9.16.0",
"globals": "^15.13.0",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^6.0.2",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.8"
"@changesets/cli": "^2.29.8",
"@eslint/js": "^10.0.1",
"@types/jsdom": "^27.0.0",
"eslint": "^10.0.0",
"globals": "^17.3.0",
"jsdom": "^28.1.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=14.16"
"node": ">=18"
},
"repository": {
"type": "git",

3376
resolvespec-js/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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,345 @@
import type {
APIResponse,
ClientConfig,
CustomOperator,
FilterOption,
Options,
PreloadOption,
SortOption,
} 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 ||
`${response.statusText} ` + `(${response.status})`,
);
}
return {
data: data,
success: true,
error: data.error ? data.error : undefined,
metadata: {
count: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
total: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
filtered: response.headers.get("content-range")
? Number(response.headers.get("content-range")?.split("/")[1])
: 0,
offset: response.headers.get("content-range")
? Number(
response.headers
.get("content-range")
?.split("/")[0]
.split("-")[0],
)
: 0,
limit: response.headers.get("x-limit")
? Number(response.headers.get("x-limit"))
: 0,
},
};
}
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;
}

View 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__"]
}

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