npm package: @seaverse/dataservice
Description: AI-Friendly Universal Data Storage SDK for TypeScript/JavaScript
Author: SeaVerse Team
Latest version: 1.8.0

# @seaverse/dataservice

A TypeScript/JavaScript SDK for universal data storage with PostgREST backend. Store and query JSON data with automatic user isolation and type safety.

## Features

- **Type-safe**: Full TypeScript support with generic types
- **Secure**: Built on PostgreSQL Row-Level Security (RLS)
- **Flexible**: Store any JSON data structure
- **Query builder**: Fluent API for complex queries
- **Auto-extraction**: Automatically extracts app ID from URL
- **UUID support**: Client-side or server-side ID generation

## Installation

```bash
npm install @seaverse/dataservice
```

## Package Exports

This package uses a **factory pattern** for client creation. Here's what it exports:

**Functions:**
- `createClient(config)` - Factory function to create a client instance (async)
- `debugSetToken(token)` - Set debug token for testing (call before createClient)
- `setAppId(appId)` - **[Deprecated]** Set application ID manually (not recommended)

**Types (TypeScript):**
- `DataServiceClient` - Type of the client instance (**not a constructor**)
- `DataRecord<T>` - Type of stored records
- `Collection<T>` - Type of collection instances
- `QueryBuilder<T>` - Type of query builder instances
- `DataTable` - Type of data table instances
- `ClientConfig` - Configuration options for createClient
- `DataServiceError` - Error class for API errors

**Constants:**
- `VERSION` - SDK version string

**Important:** Always use `createClient()` to create clients. `DataServiceClient` is a TypeScript type, not a class constructor.

```typescript
// ✓ CORRECT
import { createClient } from '@seaverse/dataservice';
const client = await createClient({});

// ✗ WRONG - DataServiceClient is not a constructor
import { DataServiceClient } from '@seaverse/dataservice';
const client = new DataServiceClient(); // This will fail!
```

## Quick Start

### Creating a Client

The SDK uses a **factory function pattern**. Always use `createClient()`:

**Important:** The SDK automatically obtains the service host from the parent page via PostMessage (500ms timeout). If the fetch fails, it defaults to `https://dataservice-api.seaverse.ai`. No configuration is needed.

```typescript
import { createClient, debugSetToken } from '@seaverse/dataservice';

// Production: Auto-fetch token and serviceHost from parent page (when running in iframe)
// Falls back to https://dataservice-api.seaverse.ai if fetch fails
const client = await createClient({});

// Development/Testing: Use debug token
debugSetToken('your-test-token');
const client = await createClient({});
```

### Basic Operations

```typescript
// Store user preferences (single record)
const userPrefs = client.userData.collection('user_preferences');
await userPrefs.insert({
  theme: 'dark',
  language: 'en',
  notifications: true,
});

// Update preferences
await userPrefs.patch(userPrefs.id, { theme: 'light' });

// Store multiple items (e.g., orders) - each needs unique collection name
const order1 = await client.userData.collection('order_001').insert({
  order_number: 'ORD-001',
  status: 'pending',
  total: 99.99,
});

const order2 = await client.userData.collection('order_002').insert({
  order_number: 'ORD-002',
  status: 'shipped',
  total: 149.99,
});

// Or use batch insert for multiple records
const orders = await client.userData.batchInsert('order', [
  { order_number: 'ORD-003', status: 'pending', total: 79.99 },
  { order_number: 'ORD-004', status: 'pending', total: 199.99 },
]);

// Delete a specific record
await client.userData.collection('order_001').delete(order1.id);
```

## Core Concepts

### Data Hierarchy

```
DataServiceClient
  └── DataTable (e.g., userData)
      └── Collection (e.g., "orders")
          └── DataRecord (JSONB data + metadata)
```

### Application ID

The SDK automatically extracts `app_id` from the current environment:

**Browser**: Extracted from URL hostname
```
https://app_8e5e867e-user_f4ed2364.app.seaverse.ai
→ appId: "app_8e5e867e-user_f4ed2364"
```

**Node.js**: Set via environment variable
```bash
export SEAVERSE_APP_ID=my-app-id
```

Access the extracted ID:
```typescript
console.log(client.appId); // "app_8e5e867e-user_f4ed2364"
```

**Manual Override (Not Recommended):**

For special cases where auto-extraction doesn't work, you can manually set the appId using `setAppId()`:

```typescript
import { setAppId, createClient } from '@seaverse/dataservice';

// Set manual appId before creating client (highest priority)
setAppId('my-custom-app-id');

// Client will use manual appId instead of auto-extraction
const client = await createClient({});
console.log(client.appId); // "my-custom-app-id"
```

⚠️ **Note:** `setAppId()` is deprecated and not recommended for normal use. The SDK automatically extracts appId from the URL, which is the recommended approach. Only use this for special cases where auto-extraction doesn't work.

**AppId Priority (from highest to lowest):**
1. `setAppId()` - Manual override (not recommended)
2. URL extraction (browser) / Environment variable (Node.js) - Automatic and recommended

### Data Tables

The SDK provides access to different data tables with different permission scopes:

- **`userData`**: User-specific data (isolated by user_id)
- More tables coming soon (public data, shared data, etc.)

### Collections

**Critical Understanding**: A collection name identifies a **single record**, not a container for multiple records.

The `(user_id, app_id, collection_name)` combination is a **unique constraint** - meaning each collection name can only store ONE record per user and app.

**To store multiple records, you MUST use unique collection names**:

```typescript
// ✓ CORRECT: Each record gets a unique collection name
await client.userData.collection('order_001').insert(order1);
await client.userData.collection('order_002').insert(order2);
await client.userData.collection('order_003').insert(order3);

// ✓ RECOMMENDED: Use batch insert (auto-generates unique names)
const insertedOrders = await client.userData.batchInsert('order', [order1, order2, order3]);
// Creates collections: order_<timestamp>_0, order_<timestamp>_1, order_<timestamp>_2

// ✗ WRONG: Reusing the same collection name
const orders = client.userData.collection('orders');
await orders.insert(order1); // ✓ Success
await orders.insert(order2); // ✗ ERROR: 409 Conflict (collection 'orders' already exists)
```

**Why this design?**
- Each collection name acts as a **unique key** for a single record
- Think of it like a key-value store: `collection_name` → single record
- For multiple records, use patterns like: `order_${orderId}`, `msg_${conversationId}_${msgId}`

## ⚠️ Common Pitfalls

### Pitfall #1: Treating Collections as Containers

```typescript
// ❌ WRONG: This looks like it should work, but it doesn't
const orders = client.userData.collection('orders');
await orders.insert({ order_number: 'ORD-001', total: 99.99 });  // ✓ Works
await orders.insert({ order_number: 'ORD-002', total: 149.99 }); // ✗ ERROR: 409 Conflict!

// ✓ CORRECT: Each record needs a unique collection name
await client.userData.collection('order_001').insert({ order_number: 'ORD-001', total: 99.99 });
await client.userData.collection('order_002').insert({ order_number: 'ORD-002', total: 149.99 });
```

**Why?** The `(user_id, app_id, collection_name)` combination is a unique constraint. Once you insert into `'orders'`, that collection name is "taken" for that user and app.

### Pitfall #2: Expecting Query Methods to Return Multiple Records

```typescript
// ❌ MISLEADING: This looks like it queries multiple orders
const orders = client.userData.collection('orders');
const pending = await orders.select().eq('data->>status', 'pending').execute();
// Returns: [] or [single order] - NOT multiple orders!

// ✓ CORRECT: To work with multiple records, track them separately
const orderIds = ['order_001', 'order_002', 'order_003'];
const allOrders = await Promise.all(
  orderIds.map(id => client.userData.collection(id).get(recordId))
);
```

**Why?** A collection name identifies a single record, so queries on that collection can only return 0 or 1 record.

### Pitfall #3: Using Plural Names for Collections

```typescript
// ❌ MISLEADING: Plural name suggests multiple items
const orders = client.userData.collection('orders');
const users = client.userData.collection('users');

// ✓ BETTER: Use singular or ID-based names
const order = client.userData.collection('order_12345');
const userProfile = client.userData.collection('user_profile');
const preference = client.userData.collection('user_preferences');
```

**Why?** Plural names create false expectations. Use singular names or include IDs to make it clear each collection is one record.

## 💡 Best Practices

### Pattern 1: Single Record per Concept

Use this for user preferences, settings, profiles - things where you only need one record:

```typescript
// User preferences (one per user)
const prefs = await client.userData.collection('preferences').insert({
  theme: 'dark',
  language: 'en',
});

// User profile (one per user)
const profile = await client.userData.collection('profile').insert({
  name: 'John Doe',
  avatar: 'https://...',
});
```

### Pattern 2: ID-Based Collection Names

Use this for multiple items of the same type:

```typescript
// Multiple orders - each with unique collection name
const order1 = await client.userData.collection(`order_${orderId1}`).insert(orderData1);
const order2 = await client.userData.collection(`order_${orderId2}`).insert(orderData2);

// Multiple conversations
const conv1 = await client.userData.collection(`conv_${convId1}`).insert(convData1);
const conv2 = await client.userData.collection(`conv_${convId2}`).insert(convData2);
```

### Pattern 3: Batch Insert for Multiple Records

Use this when creating multiple records at once:

```typescript
// Create multiple orders in one call
const orders = await client.userData.batchInsert('order', [
  { order_number: 'ORD-001', total: 99.99 },
  { order_number: 'ORD-002', total: 149.99 },
  { order_number: 'ORD-003', total: 199.99 },
]);

// Returns array of DataRecord with auto-generated collection names
// order_<timestamp>_0, order_<timestamp>_1, order_<timestamp>_2
```

### Pattern 4: Hierarchical Collection Names

Use this for nested data structures:

```typescript
// Messages within a conversation
await client.userData.collection(`conv_${convId}_msg_1`).insert(message1);
await client.userData.collection(`conv_${convId}_msg_2`).insert(message2);

// Tasks within a project
await client.userData.collection(`project_${projId}_task_1`).insert(task1);
await client.userData.collection(`project_${projId}_task_2`).insert(task2);
```

### Data Records

Each record contains:

```typescript
interface DataRecord<T> {
  id: string;              // UUID primary key
  user_id: string;         // Owner user ID
  app_id: string;          // Application ID
  collection_name: string; // Collection identifier
  data: T;                 // Your JSON data
  created_at: string;      // ISO timestamp
  updated_at: string;      // ISO timestamp
  deleted_at: string | null; // Soft delete timestamp
}
```

## API Reference

### Client Creation

```typescript
import { createClient } from '@seaverse/dataservice';

const client = await createClient({
  options?: {
    timeout?: number;       // Request timeout in ms (default: 30000)
    tokenFetchTimeout?: number; // Token fetch timeout in ms (default: 5000)
    headers?: Record<string, string>; // Additional headers
  };
});
```

**ServiceHost Auto-Detection:**

The SDK automatically fetches the service host from the parent page via PostMessage:
- **Timeout**: 500ms (hardcoded, not configurable)
- **Protocol**: Sends `{ type: 'seaverse:get_service_host', payload: { serviceName: 'dataservice' } }`
- **Fallback**: If fetch fails, defaults to `https://dataservice-api.seaverse.ai`
- **No user configuration**: ServiceHost is managed internally to prevent request failures

**Token Authentication Priority:**

The SDK uses the following priority order for obtaining authentication tokens:

1. **Debug Token (Highest Priority)** - Set via `debugSetToken()`
2. **Parent Page Token** - Auto-fetched via PostMessage when in iframe
3. **No Token** - Client created without authentication (API calls will fail with 401)

**AppId Configuration Priority:**

The SDK uses the following priority order for determining the application ID:

1. **Manual AppId (Highest Priority)** - Set via `setAppId()` (not recommended, deprecated)
2. **Auto-extraction** - Extracted from URL (browser) or environment variable (Node.js)

**Production Usage (Auto-fetch from parent):**

The SDK automatically fetches the authentication token and service host from the parent page via PostMessage when running in an iframe:

```typescript
// No configuration needed - auto-fetches token and serviceHost from parent
const client = await createClient({});

// Parent page should respond to PostMessage:
// Token request:
// Send: { type: 'seaverse:get_token' }
// Receive: { type: 'seaverse:token', payload: { accessToken: string, expiresIn: number } }
// Error: { type: 'seaverse:error', error: string }

// ServiceHost request:
// Send: { type: 'seaverse:get_service_host', payload: { serviceName: 'dataservice' } }
// Receive: { type: 'seaverse:service_host', payload: { serviceHost: string } }
// Error: { type: 'seaverse:error', error: string }
```

**Development/Testing (Debug Token):**

For development and testing, use `debugSetToken` to bypass the parent page token fetch:

```typescript
import { debugSetToken, createClient } from '@seaverse/dataservice';

// Set debug token BEFORE creating client (highest priority)
debugSetToken('your-test-token');

// Client will use debug token directly, skipping parent page fetch
const client = await createClient({});
```

**Manual AppId Override (Not Recommended):**

For special cases, you can force a specific appId using `setAppId()`, which will override auto-extraction:

```typescript
import { setAppId, createClient } from '@seaverse/dataservice';

// Current URL: https://app_auto_extracted.app.seaverse.ai
// Without setAppId, client.appId would be "app_auto_extracted"

// Force specific appId (overrides auto-extraction)
setAppId('my-custom-app-id');

const client = await createClient({});
console.log(client.appId); // "my-custom-app-id" (not "app_auto_extracted")

// All operations will use the manual appId
await client.userData.collection('test').insert({ foo: 'bar' });
// ↑ This record will be stored with app_id = "my-custom-app-id"
```

⚠️ **Warning:** `setAppId()` is deprecated. Only use it when:
- Testing in environments where URL-based extraction doesn't work
- Debugging issues with specific appIds
- Working around temporary limitations

In production, always rely on automatic appId extraction from the URL.

**Graceful Degradation:**

If token or serviceHost fetching fails, the SDK gracefully handles the failure:

- **Token fetch failure**: Creates client without authentication (API calls will return 401)
- **ServiceHost fetch failure**: Falls back to default `https://dataservice-api.seaverse.ai`

```typescript
// Client creation never throws, even if fetch fails
const client = await createClient({});

try {
  // API calls may fail with authentication errors if no token
  const data = await client.userData.collection('test').insert({ foo: 'bar' });
} catch (error) {
  if (error instanceof DataServiceError && error.statusCode === 401) {
    console.log('Authentication required - no token available');
    // Handle authentication error (e.g., show login prompt)
  }
}
```

### DataTable Methods

```typescript
// Get a collection
client.userData.collection<T>(name: string): Collection<T>

// Batch insert multiple records
client.userData.batchInsert<T>(
  baseName: string,
  records: T[]
): Promise<DataRecord<T>[]>
```

### Collection Methods

#### Create

```typescript
// Insert with auto-generated UUID
collection.insert(data: T): Promise<DataRecord<T>>

// Insert with custom UUID
collection.insert(data: T, id: string): Promise<DataRecord<T>>
```

#### Read

```typescript
// Get by ID
collection.get(id: string): Promise<DataRecord<T> | null>
collection.selectById(id: string): Promise<DataRecord<T> | null>

// Query builder
collection.select(): QueryBuilder<T>

// Search by criteria
collection.search(criteria: Partial<T>): Promise<DataRecord<T>[]>

// Count records
collection.count(): Promise<number>
```

#### Update

```typescript
// Full update (replaces entire data object)
collection.update(id: string, data: T): Promise<DataRecord<T>>

// Partial update (merges with existing data)
collection.patch(id: string, partial: Partial<T>): Promise<DataRecord<T>>
```

#### Delete

```typescript
// Delete a record by ID (permanent deletion)
collection.delete(id: string): Promise<void>
```

### Query Builder

Build complex queries with a fluent API:

```typescript
collection.select()
  // Comparison operators
  .eq(field: string, value: any)      // Equal
  .neq(field: string, value: any)     // Not equal
  .gt(field: string, value: any)      // Greater than
  .gte(field: string, value: any)     // Greater than or equal
  .lt(field: string, value: any)      // Less than
  .lte(field: string, value: any)     // Less than or equal

  // Pattern matching
  .like(field: string, pattern: string)   // Case-sensitive
  .ilike(field: string, pattern: string)  // Case-insensitive

  // Array operations
  .in(field: string, values: any[])   // Value in array

  // JSONB operations
  .contains(value: any)               // JSONB contains

  // Sorting and pagination
  .order(field: string, options?: { descending?: boolean })
  .limit(count: number)
  .offset(count: number)

  // Execute
  .execute(): Promise<DataRecord<T>[]>
  .count(): Promise<number>
```

**Field syntax for JSONB queries**:
- Use `data->>field` for text comparison: `.eq('data->>status', 'pending')`
- Use `data->field` for numeric comparison: `.gt('data->total', '100')`

### Client Utility Methods

```typescript
// Get user data statistics
client.getStats(): Promise<{
  total_records: number;
  total_collections: number;
  storage_bytes: number;
}>

// Health check
client.health(): Promise<{
  status: string;
  user_id: string;
}>
```

## Examples

### E-Commerce Orders

```typescript
import { createClient } from '@seaverse/dataservice';

type Order = {
  order_number: string;
  customer_email: string;
  items: Array<{
    product_id: string;
    quantity: number;
    price: number;
  }>;
  status: 'pending' | 'processing' | 'shipped' | 'delivered';
  total: number;
  notes?: string;
};

// For development/testing with Node.js
import { debugSetToken, createClient } from '@seaverse/dataservice';
debugSetToken(process.env.JWT_TOKEN!);
const client = await createClient({});

// Create multiple orders - each with unique collection name
const orderNumber1 = `ORD-${Date.now()}`;
const order1 = await client.userData.collection<Order>(`order_${orderNumber1}`).insert({
  order_number: orderNumber1,
  customer_email: 'customer@example.com',
  items: [
    { product_id: 'PROD-001', quantity: 2, price: 29.99 },
    { product_id: 'PROD-002', quantity: 1, price: 49.99 },
  ],
  status: 'pending',
  total: 109.97,
});

console.log('Order created:', order1.id, 'Collection:', `order_${orderNumber1}`);

// Create another order
const orderNumber2 = `ORD-${Date.now() + 1}`;
const order2 = await client.userData.collection<Order>(`order_${orderNumber2}`).insert({
  order_number: orderNumber2,
  customer_email: 'customer@example.com',
  items: [
    { product_id: 'PROD-003', quantity: 1, price: 199.99 },
  ],
  status: 'pending',
  total: 199.99,
});

// Update order status (access by collection name)
await client.userData.collection(`order_${orderNumber1}`).patch(order1.id, {
  status: 'shipped'
});

// Get specific order
const retrieved = await client.userData.collection(`order_${orderNumber1}`).get(order1.id);
console.log('Order status:', retrieved?.data.status);

// Delete a specific order
await client.userData.collection(`order_${orderNumber1}`).delete(order1.id);

// Note: To query across multiple orders, you would need to:
// 1. Store order metadata in a separate tracking collection
// 2. Or use a naming convention and iterate through known order IDs
// 3. Or use the batchInsert pattern and track the generated collection names
```

### Chat Conversations

```typescript
type Message = {
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp: string;
};

type Conversation = {
  title: string;
  model: string;
  messages: Message[];
  metadata?: {
    tokens_used?: number;
    cost?: number;
  };
};

// Create a new conversation with unique ID
const conversationId = `conv_${Date.now()}`;
const conv = await client.userData.collection<Conversation>(conversationId).insert({
  title: 'Project Planning Discussion',
  model: 'claude-3-opus',
  messages: [
    {
      role: 'user',
      content: 'Help me plan a new feature for my app',
      timestamp: new Date().toISOString(),
    },
  ],
});

console.log('Conversation created:', conversationId);

// Add assistant response to existing conversation
const current = await client.userData.collection<Conversation>(conversationId).get(conv.id);
if (current) {
  await client.userData.collection<Conversation>(conversationId).patch(conv.id, {
    messages: [
      ...current.data.messages,
      {
        role: 'assistant',
        content: 'I can help you with that. What feature are you thinking about?',
        timestamp: new Date().toISOString(),
      },
    ],
  });
}

// Store individual messages separately (alternative pattern)
// Each message gets its own collection
const msgId1 = await client.userData.collection(`${conversationId}_msg_1`).insert({
  role: 'user',
  content: 'Help me plan a new feature',
  timestamp: new Date().toISOString(),
});

const msgId2 = await client.userData.collection(`${conversationId}_msg_2`).insert({
  role: 'assistant',
  content: 'I can help with that!',
  timestamp: new Date().toISOString(),
});

// Get a specific conversation
const retrieved = await client.userData.collection(conversationId).get(conv.id);
console.log('Conversation title:', retrieved?.data.title);
```

### User Preferences

```typescript
type UserPreferences = {
  theme: 'light' | 'dark' | 'auto';
  language: string;
  notifications: {
    email: boolean;
    push: boolean;
    sms: boolean;
  };
  privacy: {
    profile_visible: boolean;
    show_activity: boolean;
  };
};

const prefs = client.userData.collection<UserPreferences>('preferences');

// Initialize preferences
await prefs.insert({
  theme: 'auto',
  language: 'en',
  notifications: {
    email: true,
    push: true,
    sms: false,
  },
  privacy: {
    profile_visible: true,
    show_activity: false,
  },
});

// Update theme only
await prefs.patch(prefId, { theme: 'dark' });
```

## UUID Primary Keys

All records use UUID strings as primary keys.

### Auto-generated UUIDs

```typescript
const order = await orders.insert({
  order_number: 'ORD-001',
  status: 'pending',
});

console.log(order.id); // "550e8400-e29b-41d4-a716-446655440000"
```

### Custom UUIDs

Useful for offline-first applications or client-side ID generation:

```typescript
// Node.js
import { randomUUID } from 'crypto';
const id = randomUUID();

// Browser
const id = crypto.randomUUID();

// With uuid library
import { v4 as uuidv4 } from 'uuid';
const id = uuidv4();

// Use custom ID
const order = await orders.insert(
  { order_number: 'ORD-002', status: 'pending' },
  id
);
```

## TypeScript Support

Full type safety with generic types:

```typescript
type Product = {
  name: string;
  price: number;
  in_stock: boolean;
};

const products = client.userData.collection<Product>('products');

const product = await products.insert({
  name: 'Widget',
  price: 29.99,
  in_stock: true,
});

// TypeScript knows the shape
console.log(product.data.name);     // ✓ string
console.log(product.data.price);    // ✓ number
console.log(product.data.invalid);  // ✗ TypeScript error
```

## Error Handling

```typescript
import { DataServiceError } from '@seaverse/dataservice';

try {
  await collection.insert(data);
} catch (error) {
  if (error instanceof DataServiceError) {
    console.error('Error code:', error.code);
    console.error('Message:', error.message);
    console.error('HTTP status:', error.statusCode);

    // Handle specific errors
    switch (error.code) {
      case '23505':
        console.log('Duplicate collection name - use a different name');
        break;
      case 'PGRST301':
        console.log('Authentication failed - check your token');
        break;
      default:
        console.log('Unexpected error:', error.message);
    }
  } else {
    console.error('Unknown error:', error);
  }
}
```

Common error codes:
- `23505`: Unique constraint violation (duplicate collection name)
- `PGRST301`: JWT authentication failed
- `PGRST204`: No rows returned
- `PGRST116`: Invalid query syntax

## Security

The SDK is built on PostgreSQL Row-Level Security (RLS):

- **JWT Authentication**: User identity from JWT token payload
- **Automatic Isolation**: Each user can only access their own data
- **No Admin Bypass**: Even database admins respect RLS policies
- **Secure by Default**: No configuration needed

Your JWT token must include a `user_id` claim:

```json
{
  "user_id": "user_f4ed2364ffcf24d6c6707d5ca5e4fe6d",
  "exp": 1735689600
}
```

## Development

### Setup

```bash
# Clone repository
git clone https://github.com/seaverse/dataservice
cd dataservice

# Install dependencies
npm install

# Build
npm run build
```

### Testing

```bash
# Copy environment template
cp .env.example .env

# Configure your credentials
# POSTGREST_URL=https://your-api.example.com
# JWT_TOKEN=Bearer your-token-here

# Run tests
npm run test:examples
```

### Scripts

- `npm run build` - Build the SDK
- `npm run dev` - Build in watch mode
- `npm run test:examples` - Run integration tests
- `npm run lint` - Lint code with ESLint
- `npm run format` - Format code with Prettier

## License

MIT

## Links

- [GitHub Repository](https://github.com/seaverse/dataservice)
- [Report Issues](https://github.com/seaverse/dataservice/issues)
- [SeaVerse Platform](https://seaverse.ai)