SeptemCore LogoSeptemCore
SDK Reference

@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-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' }],
    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.

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

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

On this page