@seaverse/dataservice
v1.8.0
Published
AI-Friendly Universal Data Storage SDK for TypeScript/JavaScript
Readme
@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
npm install @seaverse/dataservicePackage 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 recordsCollection<T>- Type of collection instancesQueryBuilder<T>- Type of query builder instancesDataTable- Type of data table instancesClientConfig- Configuration options for createClientDataServiceError- 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.
// ✓ 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.
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
// 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
export SEAVERSE_APP_ID=my-app-idAccess the extracted ID:
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():
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):
setAppId()- Manual override (not recommended)- 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:
// ✓ 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
// ❌ 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
// ❌ 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
// ❌ 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:
// 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:
// 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:
// 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>_2Pattern 4: Hierarchical Collection Names
Use this for nested data structures:
// 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:
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
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:
- Debug Token (Highest Priority) - Set via
debugSetToken() - Parent Page Token - Auto-fetched via PostMessage when in iframe
- 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:
- Manual AppId (Highest Priority) - Set via
setAppId()(not recommended, deprecated) - 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:
// 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:
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:
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
// 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
// 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
// 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
// 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
// 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
// Delete a record by ID (permanent deletion)
collection.delete(id: string): Promise<void>Query Builder
Build complex queries with a fluent API:
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->>fieldfor text comparison:.eq('data->>status', 'pending') - Use
data->fieldfor numeric comparison:.gt('data->total', '100')
Client Utility Methods
// 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
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: '[email protected]',
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: '[email protected]',
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 namesChat Conversations
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
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
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:
// 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:
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 errorError Handling
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 failedPGRST204: No rows returnedPGRST116: 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:
{
"user_id": "user_f4ed2364ffcf24d6c6707d5ca5e4fe6d",
"exp": 1735689600
}Development
Setup
# Clone repository
git clone https://github.com/seaverse/dataservice
cd dataservice
# Install dependencies
npm install
# Build
npm run buildTesting
# 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:examplesScripts
npm run build- Build the SDKnpm run dev- Build in watch modenpm run test:examples- Run integration testsnpm run lint- Lint code with ESLintnpm run format- Format code with Prettier
License
MIT
