@vaif/client
v2.1.0
Published
Official TypeScript SDK for VAIF Studio — query builder for the database, auth, realtime subscriptions, storage, functions, AI, and more. Designed for parity with Supabase/Firebase ergonomics on top of the VAIF Studio API.
Downloads
59
Maintainers
Readme
@vaif/client
Official TypeScript SDK for VAIF Studio. Query builder for the database, auth (email / OAuth / MFA / magic link), realtime subscriptions, storage, functions, AI, and more — designed for Supabase/Firebase-style ergonomics.
Looking for a raw OpenAPI client? See
@vaif/api— a 1:1 mapping of the REST surface, generated by Stainless. Most apps should use@vaif/client(this package); reach for@vaif/apionly if you need direct REST access or are building a service that mirrors the API exactly.
Installation
npm install @vaif/client
# or
pnpm add @vaif/client
# or
yarn add @vaif/clientQuick Start
import { createVaifClient } from '@vaif/client';
const vaif = createVaifClient({
baseUrl: 'https://api.vaif.studio',
projectId: 'your-project-id',
apiKey: 'vaif_pk_xxx',
});Modules
The client provides access to the following modules:
| Module | Description |
|--------|-------------|
| vaif.auth | Authentication (email, OAuth, MFA, magic link) |
| vaif.from() | Database operations with query builder |
| vaif.mongodb | MongoDB document database |
| vaif.realtime() | Real-time subscriptions and presence |
| vaif.storage | File storage and CDN |
| vaif.functions | Edge functions |
| vaif.projects | Project management |
| vaif.orgs | Organization management |
| vaif.schema | Database schema migrations |
| vaif.secrets | Secret management |
| vaif.deployments | Deployment management |
| vaif.integrations | Webhooks and events |
| vaif.billing | Billing and subscriptions |
| vaif.flags | Feature flags |
| vaif.security | Security and environment variables |
| vaif.ai | AI-powered code generation |
| vaif.templates | Project templates |
| vaif.oauth | OAuth provider configuration |
| vaif.docs | Documentation management |
Authentication
// Sign up (positional args)
const { user, session } = await vaif.auth.signUp(
'[email protected]',
'securepassword',
{ name: 'John Doe' } // optional
);
// Login
const result = await vaif.auth.login(
'[email protected]',
'securepassword'
);
// OAuth - get the sign-in URL
const { url } = await vaif.auth.getOAuthSignInUrl({
provider: 'google',
redirectUrl: 'https://myapp.com/callback',
});
// Handle OAuth callback
const { user, session } = await vaif.auth.handleOAuthCallback({
provider: 'google',
code: 'auth_code_from_callback',
});
// Magic Link
await vaif.auth.requestMagicLink({ email: '[email protected]' });
const { user, session } = await vaif.auth.verifyMagicLink({ token: 'xxx' });
// MFA (platform / legacy)
const { qrCode, secret } = await vaif.auth.setupMFA('totp');
await vaif.auth.enableMFA('123456');
const result = await vaif.auth.verifyMFA('mfa_token', '123456');
// TOTP MFA for project users — Phase 4.5
// Enroll: render QR from otpauthUri, then confirm with first code
const { enrollToken, otpauthUri, secret: totpSecret } = await vaif.auth.mfa.enrollStart();
const { backupCodes } = await vaif.auth.mfa.enrollConfirm(enrollToken, '123456');
// Show backupCodes ONCE; never persist plaintext
// Sign-in with MFA — login() returns a discriminated union; branch on
// `challengeToken in res` for the MFA-required path
const res = await vaif.auth.login('[email protected]', 'password');
if ('challengeToken' in res) {
const final = await vaif.auth.mfa.verify(res.challengeToken, '123456');
// final.accessToken is the real session
}
// Disable / rotate backup codes (require current valid code)
await vaif.auth.mfa.disable('123456');
const { backupCodes: fresh } = await vaif.auth.mfa.regenerateCodes('123456');
// Get current user
const user = await vaif.auth.getUser();Passwordless sign-in (Magic Links)
Send a one-time email link valid for 15 minutes. Single-use, with MFA honored — enrolled users complete their second factor after clicking.
// Send the link
await vaif.auth.magic.send(email, {
redirectUrl: 'https://your-app.example.com/auth/callback',
});
// User clicks the link in their email. They land at the redirectUrl with
// #access_token=... in the URL fragment (or #mfa_challenge=... if MFA is
// enrolled). Parse it the same way you'd handle an OAuth callback:
const params = new URLSearchParams(window.location.hash.slice(1));
const accessToken = params.get('access_token');
const mfaChallenge = params.get('mfa_challenge');
if (mfaChallenge) {
// Render MFA code prompt; on submit:
const session = await vaif.auth.mfa.verify(mfaChallenge, code);
// session.accessToken is the real one
}Anti-enumeration: magic.send() always resolves with { ok: true } whether or not the email matches a user. No leak of account existence.
Sessions
// Logout
await vaif.auth.logout();
// Session management
const { sessions } = await vaif.auth.listSessions();
await vaif.auth.revokeSession('session_id');
await vaif.auth.revokeOtherSessions();Database Operations
// Define your types
interface User {
id: string;
email: string;
name: string;
createdAt: string;
}
// List with filters (Supabase-style helpers)
const { data: users, error } = await vaif.from<User>('users')
.select('id', 'name', 'email')
.eq('active', true)
.orderBy('createdAt', 'desc')
.limit(10)
.list();
// Get single record — returns { data, error } tuple
const { data: user, error } = await vaif.from<User>('users')
.eq('id', 'user-123')
.single();
if (error) {
if (error.code === 'NOT_FOUND') { /* no row */ }
if (error.code === 'MULTIPLE_ROWS') { /* ambiguous filter */ }
}
// .maybeSingle() returns { data: null, error: null } when zero rows match
const { data: maybeUser } = await vaif.from<User>('users')
.eq('email', '[email protected]')
.maybeSingle();
// Create — uses the original positional create()
const newUser = await vaif.from<User>('users').create({
email: '[email protected]',
name: 'New User',
});
// Update by ID (positional)
const updated = await vaif.from<User>('users')
.update('user-123', { name: 'Updated Name' });
// Delete by ID (positional)
await vaif.from<User>('users').delete('user-123');
// Batch operations
await vaif.from<User>('users').batchCreate([
{ email: '[email protected]', name: 'User 1' },
{ email: '[email protected]', name: 'User 2' },
]);
// Pagination
const { data, total, page, pageSize } = await vaif.from<User>('users')
.paginate({ page: 1, pageSize: 20 });
// Aggregations
const stats = await vaif.from<User>('users')
.aggregate({ count: '*', avg: 'age' });MongoDB Operations
Full MongoDB support for document-based data:
// Get a collection reference
const users = vaif.mongodb.collection<User>('users');
// Find documents
const activeUsers = await users.find({
filter: { status: 'active' },
sort: { createdAt: -1 },
limit: 10,
});
// Find one document
const user = await users.findOne({ email: '[email protected]' });
// Find by ID
const userById = await users.findById('507f1f77bcf86cd799439011');
// Insert documents
const newUser = await users.insertOne({
email: '[email protected]',
name: 'New User',
createdAt: new Date(),
});
const manyUsers = await users.insertMany([
{ email: '[email protected]', name: 'User 1' },
{ email: '[email protected]', name: 'User 2' },
]);
// Update documents
await users.updateOne(
{ email: '[email protected]' },
{ $set: { name: 'Updated Name' } }
);
await users.updateMany(
{ status: 'pending' },
{ $set: { status: 'active' } }
);
// Atomic operations
const updated = await users.findOneAndUpdate(
{ email: '[email protected]' },
{ $inc: { loginCount: 1 } },
{ returnDocument: 'after' }
);
// Delete documents
await users.deleteOne({ email: '[email protected]' });
await users.deleteMany({ status: 'inactive' });
// Aggregation pipeline
const stats = await users.aggregate([
{ $match: { status: 'active' } },
{ $group: { _id: '$country', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
]);
// Count and distinct
const totalUsers = await users.count({ status: 'active' });
const countries = await users.distinct('country', { status: 'active' });
// Index management
const indexes = await users.listIndexes();
await users.createIndex({ email: 1 }, { unique: true });
await users.dropIndex('old_index');
// Bulk operations
await users.bulkWrite([
{ insertOne: { document: { email: '[email protected]' } } },
{ updateOne: { filter: { email: '[email protected]' }, update: { $set: { migrated: true } } } },
{ deleteOne: { filter: { email: '[email protected]' } } },
]);
// Collection management
const collections = await vaif.mongodb.listCollections();
await vaif.mongodb.createCollection('logs');Cursor Support
For large datasets, use cursors to paginate through results:
const cursor = await users.findWithCursor({
filter: { status: 'active' },
batchSize: 100,
});
let batch = await cursor.next();
while (batch.length > 0) {
for (const user of batch) {
console.log(user.email);
}
batch = await cursor.next();
}
await cursor.close();Realtime Subscriptions
const realtime = vaif.realtime();
await realtime.connect();
// Subscribe to table changes
realtime.subscribe(
{ table: 'messages', event: 'INSERT' },
(event) => {
console.log('New message:', event.new);
}
);
// Channel messaging
const channel = realtime.channel('room-1');
// Presence
channel.presence.track({ userId: 'user-123', status: 'online' });
channel.presence.onSync((state) => {
console.log('Online users:', Object.keys(state));
});
// Broadcast
channel.broadcast.send('typing', { userId: 'user-123' });
channel.broadcast.on('typing', (payload) => {
console.log('User typing:', payload.userId);
});
// Cleanup
realtime.unsubscribe({ table: 'messages' });
await realtime.disconnect();Storage
// Upload file
const result = await vaif.storage.upload('avatars/user-123.jpg', file, {
contentType: 'image/jpeg',
isPublic: true,
});
// Download file
const blob = await vaif.storage.download('avatars/user-123.jpg');
// Get public URL
const url = vaif.storage.getPublicUrl('avatars/user-123.jpg');
// Get signed URL (temporary access)
const signedUrl = await vaif.storage.getSignedUrl('private/doc.pdf', {
expiresIn: 3600,
});
// List files
const { files } = await vaif.storage.list({ prefix: 'avatars/' });
// Delete file
await vaif.storage.delete('avatars/old-avatar.jpg');
// Multipart uploads for large files
const upload = await vaif.storage.createMultipartUpload('large-file.zip');
await vaif.storage.uploadPart(upload.uploadId, 1, chunk1);
await vaif.storage.uploadPart(upload.uploadId, 2, chunk2);
await vaif.storage.completeMultipartUpload(upload.uploadId, parts);Edge Functions
// Invoke a function
const result = await vaif.functions.invoke('send-email', {
to: '[email protected]',
subject: 'Hello',
body: 'Welcome to VAIF!',
});
// Invoke with options
const result = await vaif.functions.invoke('process-data', payload, {
timeout: 30000,
retry: { attempts: 3, backoff: 'exponential' },
});
// Batch invoke
const results = await vaif.functions.batchInvoke([
{ name: 'func1', payload: { data: 1 } },
{ name: 'func2', payload: { data: 2 } },
]);
// List functions
const functions = await vaif.functions.list({ projectId: 'proj-123' });
// Deploy a function
await vaif.functions.deploy('my-function', {
code: functionCode,
runtime: 'node20',
});Integrations (Webhooks & Events)
// Create a webhook subscription
const subscription = await vaif.integrations.createSubscription({
type: 'webhook',
url: 'https://myapp.com/webhooks',
events: ['user.created', 'user.updated'],
secret: 'whsec_xxx',
});
// Publish custom events
await vaif.integrations.publish({
name: 'order.completed',
data: { orderId: '123', amount: 99.99 },
});
// List webhook deliveries
const deliveries = await vaif.integrations.listDeliveries({
subscriptionId: 'sub-123',
});
// Retry failed delivery
await vaif.integrations.retryDelivery('delivery-123');Query Builder: Supabase-Compatible Helpers
The query builder exposes Supabase-style convenience methods that forward to the
underlying .where() primitive. Each returns a chained TableClient<T>, so
they're freely composable.
| Method | Behavior |
|--------|----------|
| .eq(field, value) | field = value |
| .neq(field, value) | field != value |
| .gt(field, value) | field > value |
| .gte(field, value) | field >= value |
| .lt(field, value) | field < value |
| .lte(field, value) | field <= value |
| .like(field, pattern) | SQL LIKE (case-sensitive) |
| .ilike(field, pattern) | SQL ILIKE (case-insensitive) |
| .in(field, values) | field is in the array |
Chaining multiple filters combines them with logical AND:
const { data } = await vaif.from('posts')
.eq('status', 'published')
.gte('createdAt', '2026-01-01')
.ilike('title', '%vaif%')
.list();Single-row terminals
// .single() — expect exactly one row
const { data, error } = await vaif.from('users').eq('id', '123').single();
// error.code === 'NOT_FOUND' → 0 rows
// error.code === 'MULTIPLE_ROWS' → 2+ rows
// error === null && data === User → success
// .maybeSingle() — zero or one row (no error on empty result)
const { data, error } = await vaif.from('users')
.eq('email', email)
.maybeSingle();
if (error) throw error; // only fires on MULTIPLE_ROWS or network
if (!data) { /* no match */ }Numeric / Decimal Auto-Parse
Postgres returns numeric, decimal, bigint, and int8 columns as JSON
strings (to preserve arbitrary precision). The SDK can transparently parse
these into JavaScript Number or BigInt based on the value's safe range.
// Register column types (typically done at codegen time)
vaif.setColumnTypes('orders', {
id: 'uuid',
amount: 'numeric',
user_count: 'bigint',
created_at: 'timestamptz',
});
const { data } = await vaif.from('orders').eq('id', '1').single();
// data.amount → 42.5 (number)
// data.user_count → 12345n (BigInt — would overflow Number)Modes (createVaifClient({ numericMode: '...' }))
auto(default): Number when the value fits safely; BigInt on overflow; string preserved for large decimals that fit neither type.string: No-op, preserves wire format. Use if your code already expects strings or you handle parsing yourself.bigint: Always parse to BigInt. Throws on fractional inputs.
const vaif = createVaifClient({
baseUrl: '...',
apiKey: '...',
numericMode: 'string', // opt out of numeric parsing entirely
});TypeScript Support
The SDK is fully typed. Use generics for type-safe operations:
interface Post {
id: string;
title: string;
content: string;
authorId: string;
createdAt: string;
}
// Full type inference
const posts = await vaif.from<Post>('posts').list();
// posts is Post[]
// MongoDB with types
const users = vaif.mongodb.collection<User>('users');
const user = await users.findOne({ email: '[email protected]' });
// user is User | nullError Handling
Two styles are supported:
Throw-style — most CRUD methods (.create, .update, .delete, .get)
throw a VaifError subclass on failure:
import {
VaifError,
VaifAuthError,
VaifNotFoundError,
isVaifError
} from '@vaif/client';
try {
await vaif.from('users').get('invalid');
} catch (error) {
if (error instanceof VaifNotFoundError) {
console.log('User not found');
} else if (error instanceof VaifAuthError) {
console.log('Authentication failed');
} else if (isVaifError(error)) {
console.log('VAIF error:', error.message);
}
}Tuple-style (Supabase-compat) — .single(), .maybeSingle(), and
.list() return { data, error } instead of throwing per-query errors:
const { data, error } = await vaif.from('users')
.eq('id', 'invalid')
.single();
if (error) {
switch (error.code) {
case 'NOT_FOUND': // no row matched
case 'MULTIPLE_ROWS': // ambiguous filter
case 'NETWORK_ERROR': // connection / timeout
case 'UNAUTHORIZED': // 401
case 'FORBIDDEN': // 403
case 'RATE_LIMITED': // 429
default: // catch-all
}
}API Key Types: vk_pub_ vs vk_srv_
Project API keys come in two prefixed flavours so the right key ends up in the right environment.
| Prefix | Role | RLS | Safe for browser? | Use case |
|--------|------|-----|--------------------|----------|
| vk_pub_… | anon | Respected | Yes | Bundled client apps, public sites, anon access |
| vk_srv_… | service_role | Bypassed | No | Server-only code (cron jobs, edge functions, scripts) |
| vk_… (legacy) | legacy | Per-route | Project-dependent | Pre-split keys that pre-date the prefix change — still valid |
The SDK detects when a vk_srv_ key is being used in a browser context
(window present) and emits a console.warn at construction time:
[VAIF] You are using a service_role key (vk_srv_...) in a browser context.
This key BYPASSES row-level security and gives full database access.
Use a vk_pub_ (anon) key for browser bundles; keep vk_srv_ keys server-side
only.The warning is best-effort (Workers / Deno / Edge runtimes can't always be
distinguished) — treat it as a guardrail, not a substitute for the rule:
never ship a vk_srv_ key to a client bundle. Generate split keys from
the dashboard's API Keys section; legacy projects get a "Generate split
keys" upgrade button there.
Data-plane URL Shape (useLegacyDataUrls)
The data plane is canonical at /v1/data/{projectId}/{table} (Hardening
D.6). The SDK targets the canonical form automatically when projectId is
present in createVaifClient config — no migration step required.
const vaif = createVaifClient({
baseUrl: 'https://api.vaif.studio',
projectId: 'your-project-id',
apiKey: 'vk_pub_xxx',
});
// Requests go to: /v1/data/your-project-id/postsIf you need to pin to the legacy /generated/{table} path while migrating,
flip the escape hatch:
const vaif = createVaifClient({
// ...
useLegacyDataUrls: true, // pins to /generated/* until Sunset: 2026-12-01
});The legacy path still works but returns Sunset: 2026-12-01,
Deprecation: true, and Link: rel="successor-version" response headers.
Plan to remove the override before the Sunset date — see
Migrating from /generated to /v1/data.
Typed Error Codes (VaifErrorCode)
The {data, error} tuple shape exposes a typed error.code you can
switch on. The full union of 27 codes is exported as a TypeScript type:
import type { VaifErrorCode } from "@vaif/client";
const { data, error } = await client.from("posts").eq("id", "x").single();
if (error) {
switch (error.code) {
case "NOT_FOUND": return showNotFoundPage();
case "MULTIPLE_ROWS": return logBugAndShowFirst(data);
case "PROJECT_DEACTIVATED": return showReactivateCTA(error);
case "TOKEN_EXPIRED": await client.auth.refresh(); return retry();
case "RATE_LIMITED": return backoffAndRetry(error);
default: return showGenericError(error);
}
}Codes fall into three groups:
- Server-emitted auth/state —
TOKEN_MISSING,TOKEN_INVALID,TOKEN_EXPIRED,SCOPE_INSUFFICIENT,INVALID_API_KEY,PROJECT_NOT_FOUND,PROJECT_DEACTIVATED - Server-emitted query / rate-limit —
INVALID_WHERE,INVALID_SELECT,INVALID_COLUMN,LIMIT_TOO_HIGH,SQL_PARSE_ERROR,SQL_FORBIDDEN_*(set-role / search-path / extension / security-definer / copy-program / cross-schema / temp-schema / set-session-auth / reset-role),MULTI_STATEMENT_SQL_RESULT,RATE_LIMITED,QUOTA_EXCEEDED,BAD_REQUEST,INTERNAL_ERROR - SDK-side only —
NOT_FOUND,MULTIPLE_ROWS,NETWORK_ERROR,VALIDATION_ERROR,UNAUTHORIZED,FORBIDDEN,CONFLICT,UNKNOWN
The full registry mirror lives at Error Codes & Debugging.
Related Packages
- @vaif/auth - Standalone authentication client
- @vaif/react - React hooks
- @vaif/expo - React Native/Expo SDK
- @vaif/cli - CLI tools
License
MIT
