Skip to main content

@platform/sdk-data

@platform/sdk-data is the module's interface to the Platform Data Layer. The Data Layer automatically generates REST CRUD endpoints for any model declared in the module's SQL migrations (autoApi: true in manifest). The SDK wraps those endpoints with type-safe hooks and provides real-time subscriptions through WebSocket.


Installation

pnpm add @platform/sdk-data

useQuery()

React hook for reading data. Built on top of TanStack Query v5 — handles caching, background refetch, stale-while-revalidate, and deduplication automatically.

import { useQuery } from '@platform/sdk-data';

function ContactList() {
const { data, isLoading, error } = useQuery('contacts', {
filters: {
status: { eq: 'active' },
score: { gt: 50 },
},
orderBy: [{ field: 'createdAt', dir: 'desc' }],
limit: 20,
include: ['company'], // eager-load related FK record
});

if (isLoading) return <Spinner />;
if (error) return <ErrorBanner error={error} />;

return (
<ul>
{data.data.map(c => (
<li key={c.id}>{c.name}{c.company?.name}</li>
))}
</ul>
);
}

Response Shape

All list responses follow PaginatedResponse<T> from sdk-core:

{
data: Contact[],
meta: {
cursor: 'next-cursor-token',
hasMore: true,
total: 143,
}
}

Query Filter Operators

The SDK translates filter objects into the PostgREST-style query language (?field=operator.value). SQL injection is impossible — all values are parameterized via sqlc in the Go service.

OperatorExampleSQL equivalent
eq{ status: { eq: 'active' } }status = 'active'
neq{ status: { neq: 'deleted' } }status != 'deleted'
gt / lt / gte / lte{ price: { gt: 100 } }price > 100
like / ilike{ name: { ilike: 'iphone%' } }name ILIKE 'iphone%'
in{ status: { in: ['active', 'pending'] } }status IN (...)
is{ deletedAt: { is: null } }IS NULL
or{ or: [{ price: { lt: 50 } }, { status: { eq: 'sale' } }] }price < 50 OR status = 'sale'

Unknown operators or field names return 400 Bad Request (RFC 9457).

Cursor-Based Pagination

const { data, fetchNextPage, hasNextPage } = useQuery('contacts', {
limit: 20,
});

// Load next page
if (hasNextPage) {
await fetchNextPage();
}

Use cursor + limit — never use offset-based pagination. Offset pagination is unsafe on large datasets (N-scan problem). Cursor-based pagination is O(1) regardless of dataset size.


useMutation()

React hook for creating, updating, and deleting records. Automatically invalidates the relevant useQuery cache after a successful operation.

import { useMutation } from '@platform/sdk-data';

function CreateContactForm() {
const mutation = useMutation('contacts');

const handleSubmit = async (formData: NewContact) => {
await mutation.create(formData);
// TanStack Query cache for 'contacts' is automatically invalidated
};

return (
<form onSubmit={handleSubmit}>
{mutation.isError && <ErrorBanner error={mutation.error} />}
<button disabled={mutation.isPending}>Save</button>
</form>
);
}

CRUD Operations

const mutation = useMutation('contacts');

// Create
const contact = await mutation.create({
name: 'Alice Johnson',
companyId: '01j9pcomp000000000000001',
});

// Update (partial — PATCH semantics)
await mutation.update('01j9pcont000000000000001', { score: 95 });

// Delete (soft delete — sets deleted_at, data preserved)
await mutation.delete('01j9pcont000000000000001');

Optimistic Updates

For immediate UI feedback before the server confirms the operation:

const mutation = useMutation('contacts', {
optimistic: true,
onOptimisticUpdate: (cache, variables) => {
// Immediately update local cache
cache.setItem('contacts', variables.id, {
...variables,
updatedAt: new Date().toISOString(),
});
},
onError: (cache, variables) => {
// Revert optimistic update on server error
cache.revertItem('contacts', variables.id);
},
});

Transactions

Use explicit transactions when creating multiple related records atomically. include on create is not supported — use transactions:

import { kernel } from '@platform/sdk-core';

await kernel.data().transaction(async (tx) => {
const company = await tx.create('companies', { name: 'Acme Corp' });
await tx.create('contacts', {
name: 'Bob Smith',
companyId: company.id,
});
// Atomic: both succeed or both roll back
});

useRealtime()

Subscribe to live changes for a model. The SDK opens a WebSocket connection (via the Notify Service) and delivers change events as they happen:

import { useRealtime } from '@platform/sdk-data';

function LiveDashboard() {
const { events } = useRealtime('contacts', {
on: ['create', 'update'], // 'create' | 'update' | 'delete' | '*'
filters: { status: { eq: 'active' } },
});

useEffect(() => {
events.on('contact.create', (newContact) => {
console.log('New contact:', newContact);
// Update local state / invalidate query cache
});

events.on('contact.update', (updated) => {
console.log('Updated:', updated);
});

return () => events.off(); // Clean up on unmount
}, [events]);

return <div>Listening for live changes...</div>;
}

Real-time events are tenant-scoped — a module never receives events from another tenant's data.


Analytics Queries (ClickHouse)

For aggregate analytics over large datasets, use the analytics() method. This routes to ClickHouse (OLAP) instead of PostgreSQL (OLTP):

const result = await kernel.data().analytics({
model: 'orders',
select: ['status', 'COUNT(*) as count', 'SUM(amountCents) as total'],
groupBy: ['status'],
where: { createdAt: { gte: '2026-01-01T00:00:00Z' } },
});
// result.data = [{ status: 'paid', count: 1420, total: 14200000 }]
// result.lastSyncAt = '2026-04-22T01:59:48Z' ← CDC lag disclosure

Eventual consistency: The CDC pipeline (PostgreSQL → Debezium → Kafka → ClickHouse) has a lag of under 5 seconds. Analytics results include lastSyncAt to let the UI show "Data as of HH:MM:SS".


Data Limits

ParameterLimitNotes
Max record size1 MBGateway checks Content-Length before forwarding
Max JSONB field256 KBFor larger payloads use kernel.files()
Max tables per module50Prevents schema sprawl
Max include depth2 levelscontacts → company → industry is invalid
Max batch create500 recordsVia mutation.createMany()

Exceeding record size returns 400 Bad Request (RFC 9457 type: https://api.septemcore.com/problems/record-size-exceeded).


Lifecycle Hooks

Modules can intercept CRUD operations by registering hooks in module.manifest.json:

HookWhenTypeCan reject?
{model}.create.beforeBefore createSynchronous✅ Yes
{model}.create.afterAfter createAsync (Event Bus)☐ No
{model}.update.beforeBefore updateSynchronous✅ Yes
{model}.update.afterAfter updateAsync (Event Bus)☐ No
{model}.delete.beforeBefore deleteSynchronous✅ Yes
{model}.delete.afterAfter deleteAsync (Event Bus)☐ No

before hooks are Go functions executed synchronously in the request path — they can modify data or return an error to abort the operation. after hooks fire asynchronously via the Event Bus with guaranteed delivery and automatic retry.


Schema Introspection

// List all models defined by this module
const models = await kernel.data().listModels();
// ['contacts', 'companies', 'deals', 'tags']

// Describe a single model (fields, types, FK, indexes)
const schema = await kernel.data().describeModel('contacts');
// { fields: [...], foreignKeys: [...], indexes: [...] }

Data Layer reads information_schema PostgreSQL at service startup and caches it. The cache refreshes on module install or update.