@platform/sdk-data
sdk-data wraps the Platform Data Layer. Provides useQuery() and useMutation() hooks (TanStack Query under the hood), useRealtime() for WebSocket-driven live updates, PostgREST-style filtering, optimistic updates, cache invalidation, keyset-based pagination, and eager-loading via include. Backed by PostgreSQL (OLTP) + ClickHouse (OLAP) + Valkey.
@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-datauseQuery()
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' }],
pageSize: 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[],
nextPageToken: 'next-page-token-string'
}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.
| Operator | Example | SQL 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).
Keyset Pagination (AIP-158)
const { data, fetchNextPage, hasNextPage } = useQuery('contacts', {
pageSize: 20,
});
// Load next page
if (hasNextPage) {
await fetchNextPage();
}Use pageToken + pageSize — never use offset-based pagination.
Offset pagination is unsafe on large datasets (N-scan problem).
Keyset-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',
email: '[email protected]',
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 disclosureEventual consistency: The CDC pipeline (PostgreSQL → Debezium → Kafka → ClickHouse) has a lag of under 5 seconds. Analytics results include
lastSyncAtto let the UI show "Data as of HH:MM:SS".
Data Limits
| Parameter | Limit | Notes |
|---|---|---|
| Max record size | 1 MB | Gateway checks Content-Length before forwarding |
| Max JSONB field | 256 KB | For larger payloads use kernel.files() |
| Max tables per module | 50 | Prevents schema sprawl |
Max include depth | 2 levels | contacts → company → industry is invalid |
| Max batch create | 500 records | Via 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:
| Hook | When | Type | Can reject? |
|---|---|---|---|
{model}.create.before | Before create | Synchronous | ✅ Yes |
{model}.create.after | After create | Async (Event Bus) | ☐ No |
{model}.update.before | Before update | Synchronous | ✅ Yes |
{model}.update.after | After update | Async (Event Bus) | ☐ No |
{model}.delete.before | Before delete | Synchronous | ✅ Yes |
{model}.delete.after | After delete | Async (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.
@platform/sdk-auth
sdk-auth provides authentication and RBAC primitives for module authors. Exports: useAuth() (user state, login, logout, token refresh), useRBAC() (hasPermission, hasRole), requireRole() HOC guard, verifyEmail(), resetPassword(), inviteUser(), registerProvider(), listProviders(). Guard components: <RequireAuth>, <RequirePermission>, <RequireRole>.
@platform/sdk-events
sdk-events wraps the Platform Event Bus (Apache Kafka + RabbitMQ). Exports: publish() for domain events, subscribe() with built-in DLQ (3 strikes → dead-letter topic), onEvent() for browser Custom Events. Tenant isolation enforced by Gateway. Publish/subscribe RBAC via manifest.events.publishes[] and manifest.events.subscribes[].