@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.
| 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).
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
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.