@centrali-io/centrali-sdk
v6.6.0
Published
Centrali Node SDK
Readme
Centrali JavaScript/TypeScript SDK
Official Node.js SDK for Centrali - Build modern web applications without managing infrastructure.
Full documentation: docs.centrali.io — SDK guide, API reference, compute functions, orchestrations, and more.
Try it now! Explore the SDK in our Live Playground on StackBlitz - no setup required.
Installation
npm install @centrali-io/centrali-sdkQuick Start
import { CentraliSDK } from '@centrali-io/centrali-sdk';
// Initialize the SDK
const centrali = new CentraliSDK({
baseUrl: 'https://centrali.io',
workspaceId: 'your-workspace-slug', // This is the workspace slug, not a UUID
token: 'your-api-key'
});
// Create a record
const product = await centrali.createRecord('Product', {
name: 'Awesome Product',
price: 99.99,
inStock: true
});
console.log('Created product:', product.data);
// Search for records
const results = await centrali.search('Awesome');
console.log('Found:', results.data.totalHits, 'results');Authentication
The SDK supports three authentication methods. See the full authentication guide for details.
Recommended Auth by Context
| Context | Method | Why |
|---------|--------|-----|
| Browser / frontend app | Publishable key | Safe to expose in client code, scoped to specific resources |
| Server app / API route | Service account | Server-to-server, automatic token refresh |
| MCP agent | Service account (env vars) | CENTRALI_CLIENT_ID + CENTRALI_CLIENT_SECRET + CENTRALI_WORKSPACE_ID |
| App with its own auth (Clerk, Auth0) | External token / BYOT | Dynamic token callback |
Publishable Key (Frontend Apps)
Safe to use in browser code. Scoped to specific resources.
const centrali = new CentraliSDK({
baseUrl: 'https://centrali.io',
workspaceId: 'your-workspace-slug',
publishableKey: 'pk_live_your_key_here'
});External Token / BYOT (Clerk, Auth0, etc.)
Dynamic token callback for apps with their own auth.
const centrali = new CentraliSDK({
baseUrl: 'https://centrali.io',
workspaceId: 'your-workspace-slug',
getToken: async () => await clerk.session.getToken()
});Service Account (Server-to-Server)
Never use in browser code. Client secret must stay on the server.
const centrali = new CentraliSDK({
baseUrl: 'https://centrali.io',
workspaceId: 'your-workspace-slug',
clientId: process.env.CENTRALI_CLIENT_ID,
clientSecret: process.env.CENTRALI_CLIENT_SECRET
});Note:
workspaceIdis always the workspace slug (e.g.,"acme"), not a UUID.
Features
- ✅ Type-safe API with full TypeScript support
- ✅ Automatic authentication and token management
- ✅ Records management - Create, read, update, delete records
- ✅ Query operations - Powerful data querying with filters and sorting
- ✅ Saved Queries - Execute reusable, parameterized queries
- ✅ Full-text search - Search records across structures with Meilisearch
- ✅ Realtime events - Subscribe to record changes via SSE
- ✅ Compute functions - Execute serverless functions
- ✅ File uploads - Upload files to Centrali storage
- ✅ Data Validation - AI-powered typo detection, format validation, duplicate detection
- ✅ Anomaly Insights - AI-powered anomaly detection and data quality insights
- ✅ Orchestrations - Multi-step workflows with compute functions, decision logic, and delays
- ✅ Publishable keys - Scoped, browser-safe keys for frontend apps
- ✅ External auth (BYOT) - Dynamic token callback for Clerk, Auth0, etc.
- ✅ Service accounts - Automatic token refresh for server-to-server auth
Core Operations
Records
// Create a record
const record = await centrali.createRecord('StructureName', {
field1: 'value1',
field2: 123
});
// Get a record
const record = await centrali.getRecord('StructureName', 'record-id');
// Update a record
await centrali.updateRecord('StructureName', 'record-id', {
field1: 'new value'
});
// Delete a record (soft delete - can be restored)
await centrali.deleteRecord('StructureName', 'record-id');
// Hard delete a record (permanent - cannot be restored)
await centrali.deleteRecord('StructureName', 'record-id', { hard: true });
// Restore a soft-deleted record
await centrali.restoreRecord('StructureName', 'record-id');Querying records (canonical — recommended)
The canonical query surface — client.records.* — is the same query language used by every Centrali surface (HTTP, SDK, compute, console, MCP, AI). One operator vocabulary, one result envelope. New code should start here.
// Full canonical query — boolean trees, projection, sorting, paging
const open = await centrali.records.query<Order>('orders', {
resource: 'orders',
where: {
and: [
{ 'data.status': { eq: 'open' } },
{ or: [
{ 'data.amount': { gte: 100 } },
{ 'data.priority': { eq: 'high' } }
]}
]
},
sort: [{ field: 'createdAt', direction: 'desc' }],
page: { limit: 50 },
select: { fields: ['id', 'data.status', 'data.amount'] }
});
console.log(open.data, open.meta.hasMore);
// Full-text search — sugar over `query({ text: ... })`
const matches = await centrali.records.search<Order>('orders', 'urgent shipping');
// GET adapter for simple URL-param queries (bookmarkable, cacheable)
const recent = await centrali.records.list<Order>('orders', {
'data.status': 'paid',
sort: '-createdAt',
pageSize: 25,
});
// Top-level convenience — same as `records.query` when called with a QueryDefinition
const result = await centrali.queryRecords<Order>('orders', {
resource: 'orders',
where: { 'data.status': { eq: 'paid' } },
page: { limit: 100 }
});| Method | Endpoint | Use when |
|---|---|---|
| centrali.records.query(resource, def) | POST /records/query | Boolean trees, select, text, include |
| centrali.records.list(resource, urlOpts?) | GET /records/slug/:rs | Flat AND of field conditions, bookmarkable URLs |
| centrali.records.search(resource, text, opts?) | POST /records/query (text routes to search executor) | Full-text search with optional filters |
| centrali.records.test(resource, def) | POST /records/query/test | Authoring-time dry-run; surfaces unreadable_field errors |
| centrali.queryRecords(resource, def) | POST /records/query | Top-level convenience for the canonical form |
Operators (no $ prefix): eq, ne, gt, gte, lt, lte, in, nin, contains, startsWith, endsWith, hasAny, hasAll, exists. Boolean tree: and, or, not. Nested paths: dotted strings ('data.customer.email').
Result envelope: every canonical method returns QueryResult<T> = { data: T[]; meta: { limit, offset?, cursor?, nextCursor?, hasMore?, total?, processingTimeMs?, mode? } }.
The legacy
queryRecords(slug, urlOptions)form below (URL-param style with'data.field[op]'keys andsort: '-createdAt') still works — server-side it already routes through the canonical engine — but is@deprecatedsince 5.5.0. New code should pass a canonicalQueryDefinitioninstead.
Querying records (legacy URL-param form)
// Query archived (soft-deleted) records
const archived = await centrali.queryRecords('StructureName', {
includeArchived: true
});
// Query records with top-level filters
const products = await centrali.queryRecords('Product', {
'data.inStock': true,
'data.price[lte]': 100,
sort: '-createdAt',
limit: 10
});
// Same query using nested filter object (compute function style)
const products2 = await centrali.queryRecords('Product', {
filter: { 'data.inStock': true, 'data.price': { lte: 100 } },
sort: '-createdAt',
limit: 10
});Filter Syntax
The SDK supports two filter styles — use whichever you prefer:
Style 1 — Top-level filters (SDK-native):
const results = await centrali.queryRecords('Order', {
'data.status': 'active', // Exact match
'data.total[gte]': 100, // Operator with bracket notation
'data.tags[hasAny]': 'vip,premium', // Comma-separated for array ops
sort: '-createdAt',
pageSize: 25
});Style 2 — Nested filter object (same syntax as compute functions):
const results = await centrali.queryRecords('Order', {
filter: {
'data.status': 'active',
'data.total': { gte: 100 },
'data.tags': { hasAny: ['vip', 'premium'] }
},
sort: '-createdAt',
pageSize: 25
});Both styles work because the data service merges them. Use data. prefix for custom fields. System fields (id, createdAt, updatedAt, status) don't need the prefix.
Compute functions use
api.queryRecords()which has a slightly different interface — sort is an array of objects[{ field, direction }]instead of a string, andsearchFieldsis an array instead of comma-separated. See the compute function docs for details.
Filter Operators
| Operator | SDK (top-level) | SDK (nested) / Compute |
|----------|----------------|----------------------|
| Equal | 'data.status': 'active' | 'data.status': 'active' or { eq: 'active' } |
| Not equal | 'data.status[ne]': 'deleted' | 'data.status': { ne: 'deleted' } |
| Greater than | 'data.age[gt]': 18 | 'data.age': { gt: 18 } |
| Greater/equal | 'data.age[gte]': 18 | 'data.age': { gte: 18 } |
| Less than | 'data.age[lt]': 65 | 'data.age': { lt: 65 } |
| Less/equal | 'data.age[lte]': 65 | 'data.age': { lte: 65 } |
| In list | 'data.status[in]': 'a,b,c' | 'data.status': { in: ['a', 'b', 'c'] } |
| Not in list | 'data.status[nin]': 'a,b' | 'data.status': { nin: ['a', 'b'] } |
| Contains | 'data.email[contains]': '@gmail' | 'data.email': { contains: '@gmail' } |
| Starts with | 'data.name[startswith]': 'John' | 'data.name': { startswith: 'John' } |
| Ends with | 'data.email[endswith]': '.com' | 'data.email': { endswith: '.com' } |
| Has any (array) | 'data.tags[hasAny]': 'a,b' | 'data.tags': { hasAny: ['a', 'b'] } |
| Has all (array) | 'data.tags[hasAll]': 'a,b' | 'data.tags': { hasAll: ['a', 'b'] } |
Date Range Filtering
Use dateWindow to filter records by a date field:
// Records created in the last 30 days
const recent = await centrali.queryRecords('Order', {
dateWindow: {
field: 'createdAt',
from: new Date(Date.now() - 30 * 86400000).toISOString()
}
});
// Records updated in a specific range
const q1 = await centrali.queryRecords('Order', {
dateWindow: {
field: 'updatedAt',
from: '2024-01-01T00:00:00Z',
to: '2024-03-31T23:59:59Z'
}
});The dateWindow object has three properties:
| Property | Type | Description |
|----------|------|-------------|
| field | string | Date field to filter on (e.g., 'createdAt', 'updatedAt', or a custom date field) |
| from | string? | ISO 8601 lower bound (inclusive) |
| to | string? | ISO 8601 upper bound (inclusive) |
Reserved Query Parameters
When using queryRecords, the following parameter names are reserved and cannot be used as filter field names. Any other key you pass is treated as a record data filter.
| Parameter | Purpose |
|-----------|---------|
| page, pageSize, limit | Pagination |
| sort | Sorting (string with optional - prefix for descending) |
| search, searchField, searchFields | Full-text search |
| filter | Structured filter object (alternative to top-level filters) |
| fields, select | Field selection |
| expand | Reference expansion |
| includeDeleted, includeArchived, all | Include soft-deleted records |
| includeTotal | Include total count in response |
| dateWindow | Date range filtering (see above) |
To filter by a record field that shares a name with a reserved parameter, use the data. prefix:
// Filter by a field called "sort" in your record data
const results = await centrali.queryRecords('MyStructure', {
'data.sort': 'alphabetical'
});Expanding References
When querying records with reference fields, use the expand parameter to include the full referenced record data:
// Expand a single reference field
const orders = await centrali.queryRecords('Order', {
expand: 'customer'
});
// Expand multiple reference fields
const orders = await centrali.queryRecords('Order', {
expand: 'customer,product'
});
// Nested expansion (up to 3 levels deep)
const orders = await centrali.queryRecords('Order', {
expand: 'customer,customer.address'
});
// Get a single record with expanded references
const order = await centrali.getRecord('Order', 'order-id', {
expand: 'customer,items'
});Expanded data is placed in the _expanded object within the record's data:
// Response structure
{
"id": "order-123",
"data": {
"customerId": "cust-456",
"total": 99.99,
"_expanded": {
"customerId": {
"id": "cust-456",
"data": {
"name": "John Doe",
"email": "[email protected]"
}
}
}
}
}
// Access expanded data
const customerName = order.data.data._expanded.customerId.data.name;For many-to-many relationships, the expanded field contains an array:
// Many-to-many expansion
const post = await centrali.getRecord('Post', 'post-id', {
expand: 'tags'
});
// _expanded.tags will be an array of tag records
const tagNames = post.data.data._expanded.tags.map(tag => tag.data.name);Saved Queries
Saved queries are reusable, predefined queries created in the Centrali console and executed programmatically via the SDK. Pagination is defined in the query definition itself.
// List all saved queries in the workspace
const allQueries = await centrali.savedQueries.listAll();
// List saved queries for a specific collection
const employeeQueries = await centrali.savedQueries.list('employee');
// Get a saved query by name
const query = await centrali.savedQueries.getByName('employee', 'Active Employees');
console.log('Query ID:', query.data.id);
// Execute a saved query
const results = await centrali.savedQueries.execute('employee', query.data.id);
console.log('Found:', results.data.length, 'employees');
// Execute with variables (canonical operators — no `$` prefix)
// Saved query body: { where: { 'data.status': { eq: '${statusFilter}' } } }
const filteredResults = await centrali.savedQueries.execute('orders', query.data.id, {
variables: {
statusFilter: 'active',
startDate: new Date('2024-01-01'),
},
});Typed parameters (Phase 4)
Saved queries persist typed parameter declarations alongside the query body
(SmartQuery.variables). get() returns them so callers can introspect the
required shape, and executeTyped<TVars>() enforces it at the call site:
const query = await centrali.savedQueries.get('orders', 'monthly-revenue');
console.log(query.data.variables);
// { month: { type: 'datetime', required: true }, region: { type: 'string', required: true } }
interface MonthlyRevenueVars {
month: Date;
region: string;
}
const rows = await centrali.savedQueries.executeTyped<MonthlyRevenueVars>(
'orders',
'monthly-revenue',
{ month: new Date('2026-04-01'), region: 'us-east' },
);
// Compile error — wrong type for `month`:
// await centrali.savedQueries.executeTyped<MonthlyRevenueVars>('orders', 'monthly-revenue', {
// month: 'not-a-date',
// region: 'us-east',
// });executeTyped<TVars> accepts any object — there is no extends ExecuteSavedQueryValues constraint, so plain TS interfaces (no string index signature) work.
The untyped execute() form accepts Record<string, ScalarValue | ScalarValue[] | Date | Date[]> for callers that don't have a typed shape — including arrays of dates for array<datetime> declarations.
Authoring saved queries with canonical syntax
To author a saved query that uses canonical operators (eq/gte/…) and typed parameters, pass query (a canonical QueryDefinition) and variables (typed declarations). The SDK routes those calls to the canonical /saved-queries endpoints:
const created = await centrali.savedQueries.create('orders', {
name: 'Active Orders',
query: {
resource: 'orders',
where: { 'data.status': { eq: '${statusFilter}' } },
sort: [{ field: 'createdAt', direction: 'desc' }],
page: { limit: 100 },
},
variables: {
statusFilter: { type: 'string', required: true },
},
});
await centrali.savedQueries.update('orders', created.data.id, {
query: {
resource: 'orders',
where: { 'data.amount': { gte: '${minTotal}' } },
page: { limit: 50 },
},
variables: { minTotal: { type: 'number', required: true } },
});
// Dry-run a draft — exercises the same typed validator as a saved execute.
await centrali.savedQueries.test('orders', {
query: {
resource: 'orders',
where: { 'data.amount': { gte: '${minTotal}' } },
page: { limit: 5 },
},
variableDeclarations: { minTotal: { type: 'number', required: true } },
variables: { minTotal: 100 },
});Pre-Phase-4 callers may still pass queryDefinition (legacy $eq/$gte shape) instead of query; those calls route to the legacy slug-based endpoints and cannot carry typed variables declarations.
Promoting an untyped saved query to typed
Saved queries that predate Phase 4 carry variables: null and substitute placeholders as plain strings. To promote one to typed validation:
// 1. Load the row to see what placeholders are referenced
const existing = await centrali.savedQueries.get('orders', 'monthly-revenue');
// 2. Update with a `variables` declaration map. Every `${name}` referenced
// in the body must be declared, otherwise the server returns
// `unknown_variable`.
await centrali.savedQueries.update('orders', existing.data.id, {
variables: {
month: { type: 'datetime', required: true, description: 'Month bucket (UTC)' },
region: { type: 'string', required: true },
},
});
// 3. Subsequent executes are validated against the declarations server-side
// (no coercion — "123" is rejected for `number`, etc.). Type mismatches
// surface as `variable_type_mismatch`, missing required keys as
// `missing_required_variable`, extras as `extra_variable`.
//
// `Date` works for `datetime` because axios JSON-serializes it to an
// ISO-8601 string before the request — the server itself only ever sees
// the string and parses it via `Date.parse()`.
await centrali.savedQueries.executeTyped<{ month: Date; region: string }>(
'orders',
existing.data.id,
{ month: new Date('2026-04-01'), region: 'us-east' },
);Sending variables: null on update() reverts a typed row to untyped (legacy) substitution. Sending {} would put it into strict zero-variable mode and reject any remaining ${name} placeholder in the body.
Legacy {{name}} substitution still works on untyped rows during the deprecation window, but new saved queries — and any row being promoted to typed — must use canonical ${name} placeholders.
centrali.savedQueries.* is the canonical namespace, routed through the /saved-queries/* HTTP endpoints (Phase 4 of the query foundation, CEN-1198). New code should always use it.
Migration from centrali.smartQueries.* (deprecated alias)
centrali.smartQueries.* is preserved as a deprecated alias of centrali.savedQueries.* during the deprecation window — it routes through the same canonical endpoints and emits a one-shot console.warn on first use. Migrate by:
// Before
const results = await centrali.smartQueries.execute('orders', queryId);
// After
const results = await centrali.savedQueries.execute('orders', queryId);Saved-query bodies authored against the legacy operator vocabulary ({ status: { $eq: 'open' } }) keep working server-side via the legacy translator during the deprecation window. New saved-query definitions should use canonical operators ({ 'data.status': { eq: 'open' } }).
Search
Perform full-text search across workspace records using Meilisearch:
// Basic search
const results = await centrali.search('customer email');
console.log('Found:', results.data.totalHits, 'results');
results.data.hits.forEach(hit => {
console.log(hit.id, hit.structureSlug);
});
// Search with structure filter
const userResults = await centrali.search('john', {
structures: 'users'
});
// Search multiple structures with limit
const multiResults = await centrali.search('active', {
structures: ['users', 'orders'],
limit: 50
});Search Response
| Field | Type | Description |
|-------|------|-------------|
| hits | SearchHit[] | Array of matching records |
| totalHits | number | Estimated total matches |
| processingTimeMs | number | Search time in milliseconds |
| query | string | The original search query |
Realtime Events
Subscribe to record changes in real-time using Server-Sent Events (SSE):
// Subscribe to realtime events
const subscription = centrali.realtime.subscribe({
structures: ['Order'], // Filter by structure (optional)
events: ['record_created', 'record_updated'], // Filter by event type (optional)
filter: 'status = "pending"', // CFL filter expression (optional)
onEvent: (event) => {
console.log('Event received:', event.event);
console.log('Record:', event.recordSlug, event.recordId);
console.log('Data:', event.data);
},
onConnected: () => {
console.log('Connected to realtime service');
},
onDisconnected: (reason) => {
console.log('Disconnected:', reason);
},
onError: (error) => {
console.error('Realtime error:', error.code, error.message);
}
});
// Later: cleanup subscription
subscription.unsubscribe();Initial Sync Pattern
Realtime delivers only new events after connection. For dashboards and lists, always:
- Fetch current records first
- Subscribe to realtime
- Apply diffs while UI shows the snapshot
// 1. Fetch initial data
const orders = await centrali.queryRecords('Order', {
filter: 'status = "pending"'
});
renderOrders(orders.data);
// 2. Subscribe to realtime updates
const subscription = centrali.realtime.subscribe({
structures: ['Order'],
events: ['record_created', 'record_updated', 'record_deleted'],
filter: 'status = "pending"',
onEvent: (event) => {
// 3. Apply updates to UI
switch (event.event) {
case 'record_created':
addOrderToUI(event.data);
break;
case 'record_updated':
updateOrderInUI(event.recordId, event.data);
break;
case 'record_deleted':
removeOrderFromUI(event.recordId);
break;
}
}
});Event Types
| Event | Description |
|-------|-------------|
| record_created | A new record was created |
| record_updated | An existing record was updated |
| record_deleted | A record was deleted |
| validation_suggestion_created | AI detected a data quality issue |
| validation_batch_completed | Batch validation scan finished |
| anomaly_insight_created | AI detected a data anomaly |
| anomaly_detection_completed | Anomaly detection scan finished |
Subscribing to Validation Events
// Subscribe to validation events for a structure
const subscription = centrali.realtime.subscribe({
structures: ['orders'], // Filter by structure
events: ['validation_suggestion_created', 'validation_batch_completed'],
onEvent: (event) => {
if (event.event === 'validation_suggestion_created') {
console.log('New suggestion:', event.data.field, event.data.issueType);
console.log('Confidence:', event.data.confidence);
} else if (event.event === 'validation_batch_completed') {
console.log('Scan complete:', event.data.issuesFound, 'issues found');
}
}
});Subscribing to Anomaly Events
// Subscribe to anomaly detection events for a structure
const subscription = centrali.realtime.subscribe({
structures: ['orders'],
events: ['anomaly_insight_created', 'anomaly_detection_completed'],
onEvent: (event) => {
if (event.event === 'anomaly_insight_created') {
console.log('New anomaly:', event.data.title);
console.log('Severity:', event.data.severity);
console.log('Type:', event.data.insightType);
} else if (event.event === 'anomaly_detection_completed') {
console.log('Analysis complete:', event.data.insightsCreated, 'anomalies found');
console.log('Critical:', event.data.criticalCount);
}
}
});Reconnection
The SDK automatically reconnects with exponential backoff when connections drop. Configure reconnection behavior:
import { RealtimeManager } from '@centrali-io/centrali-sdk';
const realtime = new RealtimeManager(
'https://centrali.io',
'your-workspace',
() => centrali.getToken(),
{
maxReconnectAttempts: 10, // Default: 10
initialReconnectDelayMs: 1000, // Default: 1000ms
maxReconnectDelayMs: 30000 // Default: 30000ms
}
);Compute Functions
Compute functions are JavaScript code blocks that execute in a sandboxed environment. Every function uses the same signature:
async function run() {
// Three variables are available:
// - api: SDK for records, HTTP, files, crypto, logging
// - triggerParams: static config from the trigger definition
// - executionParams: dynamic data from the current invocation
const result = await api.httpRequest({
url: 'https://api.example.com/data',
method: 'POST',
body: JSON.stringify(executionParams.payload)
});
return { success: true, data: result };
}Important: Use
async function run() { ... }. Do NOT usemodule.exports.
Compute Input Contract
What triggerParams and executionParams contain depends on how the function is invoked:
| Trigger type | triggerParams | executionParams |
|--------------|-----------------|-------------------|
| HTTP trigger | Static params from trigger definition | { payload } — parsed request body |
| Endpoint trigger | Static params from trigger definition | { payload, method, headers, query } — full HTTP context |
| Scheduled trigger | Static params from trigger definition (max 64KB) | {} — empty |
| Pages action | { input, token } — from page action invocation | {} — empty |
| Orchestration step | Orchestration input + previous step outputs + decrypted encrypted params | Not used (all input arrives via triggerParams) |
The api Object
Every compute function has access to api, which provides:
| Category | Methods |
|----------|---------|
| Records | queryRecords, fetchRecord, createRecord, updateRecord, deleteRecord, bulkCreateRecords, bulkUpdateRecords, bulkDeleteRecords |
| HTTP | httpRequest, httpFetch (outbound calls to allowed domains) |
| Files | storeFile, storeAsCSV, storeAsJSON |
| Crypto | crypto.sha256, crypto.hmacSha256, crypto.rsaSign, crypto.signJwt |
| Utilities | uuid, formatDate, chunk, merge, math, evaluate, renderTemplate, log, logError |
| Conversion | toCSV, toJSON |
Secrets in Compute Functions
Compute functions have no built-in environment variables or secrets field. To pass secrets (API keys, credentials) to a compute function:
- Recommended: Wrap the function in an orchestration and use encrypted params on the compute step. Encrypted params are stored with AES-256-GCM at rest and decrypted at execution time — they arrive in
triggerParams. - Alternative: Pass secrets in the trigger invocation payload via
executionParams. This works but means the calling app is the courier for the secret.
See Orchestrations for encrypted params usage.
Invoking Triggers
Trigger invocation is asynchronous — it returns a job ID, not the execution result. Use the function runs API to poll for the result.
// Invoke an on-demand trigger
const job = await centrali.triggers.invoke('trigger-id', {
payload: { data: 'your-input-data' }
});
console.log('Job queued:', job.data);
// Poll for the result using function runs
// (The job ID maps to a function run — query by trigger to find it)
const runs = await centrali.functionRuns.listByTrigger('trigger-id', { limit: 1 });
const latestRun = runs.data.data[0];
console.log('Status:', latestRun.status); // pending | running | completed | failure | timeout
console.log('Result:', latestRun.runData); // execution output (once completed)
console.log('Error:', latestRun.errorMessage); // error details (if failed)Function Runs (Execution History)
Query execution history for debugging and observability. Access via
client.functionRuns (canonical, since 5.6.0). The legacy client.runs
accessor remains as a deprecated alias and emits a one-shot deprecation
warning.
The canonical functionRuns.query() surface (CEN-1216, since 5.6.0) is the
recommended way for new code — same operator vocabulary as records.query(),
returns the canonical { data, meta } envelope.
// Recent failures for a function
const failures = await centrali.functionRuns.query({
where: {
and: [
{ functionId: { eq: 'fn-123' } },
{ status: { eq: 'failure' } },
],
},
sort: [{ field: 'startedAt', direction: 'desc' }],
page: { limit: 50 },
});
// Workspace-wide activity in a date window with field projection
const activity = await centrali.functionRuns.query({
where: {
and: [
{ startedAt: { gte: '2026-04-01' } },
{ startedAt: { lt: '2026-05-01' } },
],
},
sort: [{ field: 'startedAt', direction: 'desc' }],
page: { limit: 100 },
select: { fields: ['id', 'functionId', 'status', 'startedAt', 'endedAt', 'errorCode'] },
});
// Authoring dry-run — validate a query without executing it
const plan = await centrali.functionRuns.test({
where: { status: { eq: 'completed' } },
page: { limit: 10 },
});
// Get a specific run by ID
const run = await centrali.functionRuns.get('run-uuid');
console.log('Status:', run.data.status);
console.log('Output:', run.data.runData);
console.log('Duration:', run.data.startedAt, '→', run.data.endedAt);The legacy functionRuns.listByTrigger() / functionRuns.listByFunction() GET surfaces
still work and are retained for back-compat — prefer functionRuns.query() with
a where: { triggerId: { eq: ... } } or { functionId: { eq: ... } } clause
for new code.
Queryable fields are the fixed-schema columns: id, functionId, triggerId,
status, startedAt, endedAt, executionSource, errorCode, errorMessage,
etc. The runData and executionContext JSONB columns are intentionally not
queryable — use functionRuns.get(runId) to fetch a specific run's payload.
Run statuses: pending → running → completed | failure | timeout
File Uploads
// Upload a file (defaults to /root/shared)
const result = await centrali.uploadFile(file);
const renderId = result.data; // e.g., "kvHJ4ipZ3Q6EAoguKrWmU7KYyDHcU03C"
// Upload to a specific folder (must exist, use full path)
const result = await centrali.uploadFile(file, '/root/shared/images');
// Upload as public file
const result = await centrali.uploadFile(file, '/root/shared/public', true);
// Get URLs for the uploaded file
const renderUrl = centrali.getFileRenderUrl(renderId); // For inline display
const downloadUrl = centrali.getFileDownloadUrl(renderId); // For download
// Get URL with image transformations
const thumbnailUrl = centrali.getFileRenderUrl(renderId, {
width: 200,
quality: 80,
format: 'webp'
});Audio & video files are supported up to 500 MB. The storage service supports HTTP range requests (Accept-Ranges: bytes), so render URLs work with standard <audio> and <video> elements — seeking and progressive playback work out of the box:
<video src={centrali.getFileRenderUrl(renderId)} controls />
<audio src={centrali.getFileRenderUrl(renderId)} controls />Data Validation
AI-powered data quality validation to detect typos, format issues, duplicates, and semantic errors:
// Trigger a batch validation scan on a structure
const batch = await centrali.validation.triggerScan('orders');
console.log('Scan started:', batch.data.batchId);
console.log('Records to scan:', batch.data.total);
// Wait for the scan to complete (with polling)
const result = await centrali.validation.waitForScan(batch.data.batchId, {
pollInterval: 5000, // Check every 5 seconds
timeout: 300000 // Timeout after 5 minutes
});
console.log('Issues found:', result.data.issuesFound);
// List pending validation suggestions
const suggestions = await centrali.validation.listSuggestions({
status: 'pending',
issueType: 'typo' // 'format' | 'typo' | 'duplicate' | 'semantic'
});
// Review a suggestion
for (const suggestion of suggestions.data) {
console.log(`Field: ${suggestion.field}`);
console.log(`Original: ${suggestion.originalValue}`);
console.log(`Suggested: ${suggestion.suggestedValue}`);
console.log(`Confidence: ${suggestion.confidence}`);
}
// Accept a suggestion (applies the fix to the record)
const accepted = await centrali.validation.accept('suggestion-id');
if (accepted.data.recordUpdated) {
console.log('Fix applied successfully');
}
// Reject a suggestion
await centrali.validation.reject('suggestion-id');
// Bulk accept high-confidence suggestions
const highConfidence = suggestions.data.filter(s => s.confidence >= 0.95);
await centrali.validation.bulkAccept(highConfidence.map(s => s.id));
// Get validation summary
const summary = await centrali.validation.getSummary();
console.log('Pending:', summary.data.pending);
console.log('By type:', summary.data.byIssueType);
// Get pending count for a structure
const count = await centrali.validation.getPendingCount('structure-uuid');
console.log('Pending issues:', count.data.pendingCount);Validation Issue Types
| Type | Description |
|------|-------------|
| format | Format validation (email, phone, URL patterns) |
| typo | Spelling mistakes in text fields |
| duplicate | Duplicate records detected |
| semantic | Logical inconsistencies (e.g., end date before start date) |
Anomaly Insights
AI-powered anomaly detection to identify unusual patterns in your data:
// Trigger anomaly analysis for a structure
const result = await centrali.anomalyInsights.triggerAnalysis('orders');
if (result.data.success) {
console.log('Analysis started:', result.data.batchId);
}
// List all active insights
const insights = await centrali.anomalyInsights.list({
status: 'active',
severity: 'critical' // 'info' | 'warning' | 'critical'
});
// List insights for a specific structure
const orderInsights = await centrali.anomalyInsights.listByStructure('orders');
// Get a single insight
const insight = await centrali.anomalyInsights.get('insight-id');
console.log('Title:', insight.data.title);
console.log('Description:', insight.data.description);
console.log('Affected records:', insight.data.affectedRecordIds);
// Acknowledge an insight (mark as reviewed)
await centrali.anomalyInsights.acknowledge('insight-id');
// Dismiss an insight (mark as not relevant)
await centrali.anomalyInsights.dismiss('insight-id');
// Bulk acknowledge multiple insights
await centrali.anomalyInsights.bulkAcknowledge(['id1', 'id2', 'id3']);
// Get insights summary
const summary = await centrali.anomalyInsights.getSummary();
console.log('Active:', summary.data.active);
console.log('By severity:', summary.data.bySeverity);Insight Types
| Type | Description |
|------|-------------|
| time_series_anomaly | Unusual patterns in time-series data |
| statistical_outlier | Values significantly outside normal ranges |
| pattern_deviation | Deviations from expected data patterns |
| volume_spike | Unusual spikes or drops in data volume |
| missing_data | Missing or null values in required fields |
| orphaned_reference | References to deleted or non-existent records |
| reference_integrity | Broken relationships between records |
Orchestrations
Multi-step workflows that chain compute functions together with conditional logic, delays, and decision branches:
// List all orchestrations
const orchestrations = await centrali.orchestrations.list();
// Get an orchestration by ID
const orch = await centrali.orchestrations.get('orch-id');
console.log('Name:', orch.data.name);
console.log('Status:', orch.data.status);
console.log('Steps:', orch.data.steps.length);
// Create a new orchestration
const newOrch = await centrali.orchestrations.create({
slug: 'order-processing',
name: 'Order Processing Workflow',
trigger: { type: 'on-demand' },
steps: [
{
id: 'validate',
type: 'compute',
functionId: 'func_validate_order',
onSuccess: { nextStepId: 'check-result' }
},
{
id: 'check-result',
type: 'decision',
cases: [
{
conditions: [{ path: 'steps.validate.output.isValid', op: 'eq', value: true }],
nextStepId: 'fulfill'
}
],
defaultNextStepId: 'reject'
},
{
id: 'fulfill',
type: 'compute',
functionId: 'func_fulfill_order'
},
{
id: 'reject',
type: 'compute',
functionId: 'func_reject_order'
}
]
});
// Trigger an orchestration run
const run = await centrali.orchestrations.trigger('orch-id', {
input: {
orderId: '12345',
customerId: 'cust_abc'
}
});
console.log('Run started:', run.data.id);
console.log('Status:', run.data.status);
// Get run status
const runStatus = await centrali.orchestrations.getRun('orch-id', run.data.id);
console.log('Current step:', runStatus.data.currentStepId);
console.log('Has errors:', runStatus.data.hasErrors);
// Get run with step history
const runWithSteps = await centrali.orchestrations.getRun('orch-id', run.data.id, true);
for (const step of runWithSteps.data.steps || []) {
console.log(`${step.stepId}: ${step.status}`);
}
// List runs for an orchestration
const runs = await centrali.orchestrations.listRuns('orch-id', {
status: 'completed', // Filter by status
limit: 10
});
// Activate an orchestration (enables scheduled/event triggers)
await centrali.orchestrations.activate('orch-id');
// Pause an orchestration (disables all triggers)
await centrali.orchestrations.pause('orch-id');
// Update an orchestration
await centrali.orchestrations.update('orch-id', {
name: 'Updated Name',
status: 'active'
});
// Delete an orchestration
await centrali.orchestrations.delete('orch-id');Trigger Types
| Type | Description |
|------|-------------|
| on-demand | Manual invocation via API |
| event-driven | React to record events (created, updated, deleted) |
| scheduled | Run on a schedule (cron, interval, or once) |
| http-trigger | External webhook endpoint |
Step Types
| Type | Description |
|------|-------------|
| compute | Execute a compute function |
| decision | Branch based on conditions |
| delay | Wait for a duration |
Run Statuses
| Status | Description |
|--------|-------------|
| pending | Run is queued |
| running | Run is executing |
| waiting | Run is waiting (delay step) |
| completed | Run finished successfully |
| failed | Run failed with error |
Webhook Subscriptions
Outbound webhooks for record events. Centrali POSTs a signed JSON payload to your URL and records every delivery attempt; failed deliveries retry with backoff and can be replayed or cancelled individually.
import { CentraliSDK, RecordEvents } from '@centrali-io/centrali-sdk';
// Create a subscription — the signing secret is returned ONCE on create
const { data: sub } = await centrali.webhookSubscriptions.create({
name: 'Order notifications',
url: 'https://api.example.com/hooks/centrali',
events: [RecordEvents.CREATED, RecordEvents.UPDATED],
recordSlugs: ['orders'], // omit for all collections
});
// `secret` is typed `string | undefined` because reads omit it; create/rotate
// always populate it, so assert here to keep strict TypeScript happy.
console.log('Signing secret:', sub.secret!); // copy now — not returned on reads// Rotate the signing secret (immediate cutover)
const { data: rotated } = await centrali.webhookSubscriptions.rotateSecret(sub.id);
console.log('New secret:', rotated.secret!);
// List, update, delete
const { data: all } = await centrali.webhookSubscriptions.list();
await centrali.webhookSubscriptions.update(sub.id, { active: false });
await centrali.webhookSubscriptions.delete(sub.id);Delivery History & Replay
// List deliveries (rows omit requestPayload/responseBody — use .get() for those)
const deliveries = await centrali.webhookSubscriptions.deliveries.list(sub.id, {
status: 'failed',
since: new Date(Date.now() - 24 * 60 * 60 * 1000),
limit: 50,
});
// Fetch a single delivery with full payload and response body
const delivery = await centrali.webhookSubscriptions.deliveries.get(sub.id, deliveryId);
console.log('Payload:', delivery.data.requestPayload);
console.log('Response:', delivery.data.httpStatus, delivery.data.responseBody);
// Replay a failed delivery — reuses the original payload and signature
await centrali.webhookSubscriptions.deliveries.retry(deliveryId);
// Cancel a delivery that is currently retrying
await centrali.webhookSubscriptions.deliveries.cancel(deliveryId);Event Types
| Event | Emitted When |
|-------|-------------|
| record_created | A new record is inserted |
| record_updated | An existing record is modified |
| record_deleted | A record is deleted (soft or hard) |
| records_bulk_created | Multiple records are inserted in one batch |
Outbound Payload
Centrali POSTs this JSON body to your URL:
{
"event": "record_created", // 'record_created' | 'record_updated' | 'record_deleted' | 'records_bulk_created'
"workspaceSlug": "acme",
"recordSlug": "orders", // collection slug (may be absent on some bulk events)
"recordId": "3f2c…-…-…", // UUID of the affected record
"data": { /* record snapshot */ },// present on create/update; absent on delete
"timestamp": "2026-04-22T09:31:04.112Z",
"createdBy": "user_abc" // actor — only the field for the matching event
// (createdBy for create, updatedBy for update, deletedBy for delete)
}Verify the signature against the raw bytes of this body before parsing it.
Signature Verification
Every dispatch includes an X-Signature header containing a base64 HMAC-SHA256 of the raw request body. The signing secret has the form whsec_<base64url>; strip the prefix and base64url-decode the rest to get the raw key bytes.
import crypto from 'crypto';
function verifyCentraliSignature(rawBody: string, header: string, secret: string): boolean {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64url');
const expected = crypto.createHmac('sha256', key).update(rawBody).digest('base64');
const received = Buffer.from(header);
const expectedBuf = Buffer.from(expected);
return received.length === expectedBuf.length && crypto.timingSafeEqual(received, expectedBuf);
}Delivery Statuses
| Status | Description |
|--------|-------------|
| success | Endpoint returned 2xx |
| retrying | Awaiting next retry attempt |
| failed | Exhausted all retries, or cancelled |
TypeScript Support
The SDK includes full TypeScript definitions for type-safe development:
interface Product {
name: string;
price: number;
inStock: boolean;
}
const product = await centrali.createRecord<Product>('Product', {
name: 'Laptop',
price: 999.99,
inStock: true
});
// TypeScript knows product.data is of type Product
console.log(product.data.price);Error Handling
try {
const record = await centrali.createRecord('Product', productData);
console.log('Success:', record);
} catch (error) {
if (error.response?.status === 400) {
console.error('Validation error:', error.response.data);
} else if (error.response?.status === 401) {
console.error('Authentication failed');
} else {
console.error('Error:', error.message);
}
}Documentation
📚 Full documentation available at: docs.centrali.io
Examples
Check out complete example applications:
Support
License
ISC © Blueinit
Built with ❤️ by the Centrali team
