Appearance
SSE Integration Guide
Complete reference for connecting to SchemaStack's real-time Server-Sent Events streams.
SSE Endpoints
1. View Stream — /sse/view/{orgSlug}/{workspaceSlug}/{viewUuid}/stream
Auth: JWT + VIEW_DATA permission (checked at connection time) Use: Cell edits, row inserts, column changes, bulk operations for a specific view.
typescript
const viewSSE = new EventSource(
`${BASE_URL}/sse/view/${orgSlug}/${workspaceSlug}/${viewUuid}/stream`,
{ headers: { Authorization: `Bearer ${jwt}` } }
);2. Workspace Stream — /sse/workspace/{orgSlug}/{workspaceSlug}/stream
Auth: JWT + workspace membership (org OWNER/ADMIN get automatic access) Use: View lifecycle, column structural changes, workspace config, member changes.
typescript
const workspaceSSE = new EventSource(
`${BASE_URL}/sse/workspace/${orgSlug}/${workspaceSlug}/stream`,
{ headers: { Authorization: `Bearer ${jwt}` } }
);3. Organisation Stream — /sse/organisation/{orgSlug}/stream
Auth: JWT + organisation membership Use: Workspace lifecycle, org settings, member changes, dashboard stats, subscription updates.
Supports X-Client-Id header for echo filtering.
typescript
const orgSSE = new EventSource(
`${BASE_URL}/sse/organisation/${orgSlug}/stream`,
{ headers: { Authorization: `Bearer ${jwt}`, 'X-Client-Id': clientId } }
);4. Task Completions — /sse/task-completions/stream
Auth: JWT required Use: Schema processing task completion notifications. Query param: ?workspaceId={slug} (optional filter)
typescript
const taskSSE = new EventSource(
`${BASE_URL}/sse/task-completions/stream?workspaceId=${workspaceSlug}`
);Health Endpoints
| Endpoint | Returns |
|---|---|
GET /sse/view/{org}/{ws}/{viewUuid}/health | { viewUuid, activeConnections } |
GET /sse/view/health | { totalActiveViewConnections } |
GET /sse/workspace/{org}/{ws}/health | { workspace, activeConnections } |
GET /sse/workspace/health | { totalActiveConnections } |
GET /sse/organisation/{orgSlug}/health | { organisation, activeConnections } |
GET /sse/organisation/health | { totalActiveConnections } |
GET /sse/task-completions/health | { workspace, activeConnections } |
Connection Events
On successful connection, each stream sends an initial event:
event: connection
data: {"eventType":"connection.established","timestamp":"2026-03-14T12:00:00Z",...}All streams send keepalive pings every 30 seconds.
Event Envelope (WorkspaceEvent)
Every event (except task completions) follows this JSON structure:
typescript
interface WorkspaceEvent {
eventType: string; // Dot-notation type (see tables below)
workspaceId: string; // Workspace slug
entityType: string; // "View", "ViewColumn", "Row", "Workspace", etc.
entityId: string; // UUID of affected entity
operation: string; // "CREATE" | "UPDATE" | "DELETE" | "ARCHIVE" | "RESTORE"
status: string; // "STARTED" | "COMPLETED" | "FAILED"
timestamp: string; // ISO 8601
userId: string; // UUID of acting user
originClientId?: string; // For echo filtering (see below)
organisationSlug?: string; // Present when event routes to org stream
data: Record<string, any>; // Event-specific payload (includes viewUuid for view events)
}Payload Strategy
Events use one of four data payload strategies depending on the event type:
| Strategy | When Used | Frontend Action |
|---|---|---|
| Changeset | Entity updated — only changed fields sent as { changes: { field: newValue, ... } } | Merge changes into local state |
| Full object | Row/cell data, computed stats — complete object sent | Replace local state |
| Entity | Entity created — full DTO sent as { entity: { ...allFields } } | Insert into local state |
| Identifiers | Entity deleted/toggled — UUIDs sent, FE refetches | Refetch from REST API |
| Signal | Config events where details are sensitive or unnecessary — empty {} | Refetch or show notification |
Event Types — Complete Reference
view.* — View Stream Events
All view.* events are delivered to the View stream only. Columns, data, presets, constraints, files, members, guests — everything scoped to an open spreadsheet.
| Event Type | Strategy | data fields |
|---|---|---|
view.column.created | Entity | viewUuid, entity: { columnUuid, columnName } |
view.column.updated | Changeset | columnUuid, columnName, viewUuid, changes: { ...changedFields } |
view.column.deleted | Identifiers | displayName, columnName, tableName, viewUuid, message |
view.column.schema.changing | Signal | columnUuid, columnName, viewUuid, reason, impactLevel?, blocksReads?, blocksWrites?, estimatedDurationMs? |
view.column.schema.progress | Full object | columnUuid, viewUuid, progressPercent, phase, progressSource, elapsedMs, estimatedDurationMs, totalRows?, rowsProcessed? |
view.column.schema.changed | Signal | columnUuid, columnName, viewUuid, message |
view.data.row.inserted | Full object | viewUuid, row: { ...completeRowWithPK } |
view.data.cell.edited | Full object | viewUuid, columnUuid, rowId, row: { ...completeRow } |
view.data.cells.edited | Full object | viewUuid, columnUuids, rowId, row: { ...completeRow } |
view.data.column.filled | Identifiers | viewUuid, columnUuid, rowsAffected, value |
view.data.bulk.edited | Identifiers | viewUuid, columnUuids, rowIds, rowsAffected |
view.data.bulk.delete | Signal | jobId, viewUuid, viewName, successCount, errorCount, totalProcessed, message |
view.data.bulk.update | Signal | jobId, viewUuid, viewName, successCount, errorCount, totalProcessed, message |
view.data.bulk.duplicate | Signal | jobId, viewUuid, viewName, successCount, errorCount, totalProcessed, message |
view.data.bulk.export | Signal | jobId, viewUuid, viewName, fileName, fileSize, mimeType, rowCount, message |
view.constraint.created | Entity | columnUuid?, entity: { ...constraintDTO } |
view.constraint.updated | Changeset | columnUuid?, changes: { ...changedFields } |
view.constraint.deleted | Identifiers | columnUuid? |
view.constraint.toggled | Identifiers | columnUuid? |
view.constraint.reordered | Signal | {} |
view.entity-constraint.created | Entity | viewUuid?, entity: { ...entityConstraintDTO } |
view.entity-constraint.updated | Changeset | viewUuid?, changes: { ...changedFields } |
view.entity-constraint.deleted | Identifiers | viewUuid? |
view.entity-constraint.toggled | Identifiers | viewUuid? |
view.preset.created | Entity | viewUuid?, entity: { ...filterPresetDTO } |
view.preset.updated | Changeset | viewUuid?, changes: { ...changedFields } |
view.preset.deleted | Identifiers | viewUuid? |
view.file.uploaded | Entity | fileId?, viewUuid?, entity: { ...fileReference } |
view.file.deleted | Identifiers | fileId?, viewUuid? |
view.member.added | Entity | viewUuid, entity: { memberEmail, role, source } |
view.member.removed | Identifiers | viewUuid, memberEmail |
view.member.role.changed | Changeset | viewUuid, memberEmail, changes: { previousRole, newRole } |
view.guest.created | Entity | viewUuid, entity: { guestUuid, role, expiresAt? } |
view.guest.revoked | Identifiers | viewUuid, guestUuid |
view.access.updated | Changeset | viewUuid, changes: { ...changedFlags } |
view.schema.updated | Signal | viewUuid, viewSlug, viewName, message |
workspace.* — Workspace Stream Events
Delivered to: Workspace stream + Organisation stream. Includes view lifecycle (create/delete/archive) and workspace admin events.
| Event Type | Strategy | data fields |
|---|---|---|
workspace.view.created | Entity | entity: { viewSlug, viewName, tableName, message } |
workspace.view.updated | Changeset | viewUuid, viewSlug, viewName, changes: { ...changedFields } |
workspace.view.deleted | Identifiers | viewSlug, viewName, tableName, message |
workspace.view.archived | Identifiers | viewSlug, viewName |
workspace.view.restored | Identifiers | viewSlug, viewName |
workspace.status.changed | Changeset | changes: { previousStatus, newStatus } |
workspace.settings.updated | Changeset | changes: { ...changedFields } |
workspace.database.configured | Signal | databaseName, connectionStatus |
workspace.storage.configured | Signal | {} |
workspace.api.configured | Signal | maxExpandDepth?, corsAllowedOrigins? |
workspace.mcp.configured | Signal | accessMode |
workspace.member.added | Entity | entity: { memberEmail, role, source } |
workspace.member.removed | Identifiers | memberEmail |
workspace.member.role.changed | Changeset | memberEmail, changes: { previousRole, newRole } |
workspace.apikey.created | Entity | entity: { keyName, keyPrefix, createdAt } |
workspace.apikey.revoked | Signal | {} |
workspace.apikey.rotated | Changeset | changes: { keyName, keyPrefix } |
workspace.mcpkey.created | Entity | entity: { keyName, keyPrefix, createdAt } |
workspace.mcpkey.revoked | Signal | {} |
workspace.mcpkey.rotated | Changeset | changes: { keyName, keyPrefix } |
workspace.stats.updated | Full object | viewCount, memberCount, storageBytes, lastActivity, ... |
workspace.schema.checked | Full object | hasDrift, lastSynced, checkedAt, viewCount, source? ("sync" or absent), storedHash?, currentHash? |
workspace.schema.reset | Signal | {} |
organization.* — Organisation Events
Delivered to: Organisation stream only
| Event Type | Strategy | data fields |
|---|---|---|
organization.workspace.created | Entity | entity: { workspaceName, workspaceSlug, createdByEmail } |
organization.workspace.updated | Changeset | workspaceName, workspaceSlug, changes: { ...changedFields } |
organization.workspace.deleted | Identifiers | workspaceName |
organization.settings.updated | Changeset | changes: { ...changedFields } |
organization.member.created | Entity | entity: { email, role, status } |
organization.member.updated | Changeset | changes: { email, role, status } |
organization.member.deleted | Identifiers | email |
organization.subscription.updated | Changeset | changes: { planName, status } |
dashboard.* — Dashboard Events
Delivered to: Organisation stream only
| Event Type | Strategy | data fields |
|---|---|---|
dashboard.updated | Full object | complete dashboard payload (usage stats + recent activity) |
Three-Tier Routing Summary
Event prefix │ View SSE │ Workspace SSE │ Org SSE
──────────────────────────────────────┼──────────┼───────────────┼─────────
data.* │ ✓ │ — │ —
view.column.* (incl. schema.changing, │ ✓ │ — │ —
schema.progress, schema.changed), │ │ │
view.member.*, view.guest.*, │ │ │
view.access.*, view.schema.* │ │ │
view.created/updated/deleted/ │ — │ ✓ │ —
archived/restored │ │ │
workspace.* │ — │ ✓ │ ✓
organization.* │ — │ — │ ✓
dashboard.* │ — │ — │ ✓Echo Filtering
Prevents the originating client tab from double-applying its own mutations.
Setup
- Generate a UUID per browser tab (persists for tab lifetime):
typescript
const CLIENT_ID = crypto.randomUUID();- Send
X-Client-Idheader on every REST mutation and on the SSE connection:
typescript
// REST mutations
fetch('/api/views/...', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${jwt}`,
'X-Client-Id': CLIENT_ID
}
});
// SSE connection (org stream supports it natively)
const orgSSE = new EventSource(url, {
headers: { 'X-Client-Id': CLIENT_ID }
});- Filter incoming events:
typescript
sseSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// Skip events that originated from this tab
if (data.originClientId === CLIENT_ID) {
return;
}
// Process the event
handleEvent(data);
};Handling workspace.schema.checked
Fired when any admin runs a drift check (quick or full) or a schema sync. Delivered to the workspace stream and organisation stream. Use echo filtering to skip the event on the tab that triggered the check.
Payload
typescript
interface SchemaCheckedData {
// Always present (quick check + sync)
hasDrift: boolean;
checkedAt: string; // ISO 8601
viewCount: number;
// Present on quick check
storedHash?: string;
currentHash?: string;
lastSynced?: string; // ISO 8601
// Present on sync completion
source?: 'sync'; // Absent for drift checks
}Recommended handling
typescript
case 'workspace.schema.checked': {
const d = event.data;
// Update local schema status panel
store.dispatch(updateSchemaStatus({
hasDrift: d.hasDrift,
lastChecked: d.checkedAt,
lastSynced: d.lastSynced ?? d.checkedAt, // sync sets both
viewCount: d.viewCount,
}));
// Optionally show a toast when drift is detected by another admin
if (d.hasDrift) {
toast.warn('Schema drift detected — database has changed since last sync');
}
// If source is 'sync', another admin just synced — refresh views/columns
if (d.source === 'sync') {
store.dispatch(refetchViews());
}
break;
}When is it fired?
| Trigger | source | hasDrift | Notes |
|---|---|---|---|
GET .../check-drift/quick | absent | true / false | Fast hash comparison |
GET .../check-drift | absent | true / false | Full structural diff |
POST .../sync-schema | "sync" | false | Always false — just synced |
Handling Migration Progress Events
Schema migrations (column type changes, adding NOT NULL, etc.) can take seconds to minutes on large tables. The system provides a three-event lifecycle for real-time migration feedback.
Event Lifecycle
view.column.schema.changing → view.column.schema.progress (0..N) → view.column.schema.changed
(lock) (progress updates) (unlock)schema.changing— Migration queued, lock the column/view in the UIschema.progress— Real-time progress updates every 2 seconds (only for migrations > 1s)schema.changed— Migration completed (success or failure), unlock the UI
view.column.schema.changing — Lock Event
Fired when a schema migration is queued. Use the impactLevel to decide how much of the UI to lock.
typescript
interface SchemaChangingData {
columnUuid: string;
columnName: string;
viewUuid: string;
reason: string; // "Changing column type to INTEGER"
// Migration impact (present for async migrations)
impactLevel?: 'TRANSPARENT' | 'BRIEF' | 'BLOCKING';
blocksReads?: boolean; // true = SELECT blocked (PG type changes)
blocksWrites?: boolean; // true = INSERT/UPDATE/DELETE blocked
estimatedDurationMs?: number; // Predicted duration based on row count + history
}Important: Do NOT echo-filter this event. The originating client also needs to show migration state.
view.column.schema.progress — Progress Event
Fired every ~2 seconds during long-running migrations. Delivered to the View stream.
typescript
interface SchemaProgressData {
columnUuid: string;
viewUuid: string;
progressPercent: number; // 0–100, or -1 if indeterminate
phase: string; // Current operation phase (see table below)
progressSource: 'PG_STAT' | 'TIME_ESTIMATE'; // How progress was measured
elapsedMs: number; // Wall time since migration started
estimatedDurationMs: number; // Original estimate from impact analysis
totalRows?: number; // Total rows in table (when known)
rowsProcessed?: number; // Rows processed so far (PG_STAT only)
}Progress source:
| Source | When Used | Accuracy |
|---|---|---|
PG_STAT | PostgreSQL 12+ ALTER TABLE operations | Block-level accuracy from pg_stat_progress_alter_table |
TIME_ESTIMATE | MySQL, PostgreSQL < 12, or non-ALTER operations | Elapsed time / estimated duration (linear) |
PostgreSQL phases (from pg_stat_progress_alter_table):
| Phase | Description |
|---|---|
Waiting | Waiting for lock acquisition |
Rewriting table | Full table rewrite in progress (type change) |
Validating constraint | Scanning rows for NOT NULL / CHECK validation |
Building index | Creating or rebuilding an index |
Rebuilding index | Rebuilding indexes after rewrite |
Completed | Migration finished |
Time-based phases (MySQL / fallback):
| Phase | Description |
|---|---|
Executing migration | Migration is running (no phase detail available) |
Completed | Migration finished |
view.column.schema.changed — Unlock Event
Fired when the migration completes (success or failure).
typescript
interface SchemaChangedData {
columnUuid: string;
columnName: string;
viewUuid: string;
message: string; // "Schema change completed" or error message
}The status field on the WorkspaceEvent envelope tells you the outcome:
status: "COMPLETED"→ success, refresh column metadatastatus: "FAILED"→ failure, show error toast, unlock UI
Recommended Frontend Implementation
typescript
// Per-column migration state
interface MigrationState {
columnUuid: string;
impactLevel: string;
blocksReads: boolean;
blocksWrites: boolean;
progressPercent: number;
phase: string;
elapsedMs: number;
estimatedDurationMs: number;
}
const activeMigrations = new Map<string, MigrationState>();
function handleSSEEvent(event: WorkspaceEvent) {
switch (event.eventType) {
case 'view.column.schema.changing': {
const d = event.data;
activeMigrations.set(d.columnUuid, {
columnUuid: d.columnUuid,
impactLevel: d.impactLevel ?? 'TRANSPARENT',
blocksReads: d.blocksReads ?? false,
blocksWrites: d.blocksWrites ?? false,
progressPercent: 0,
phase: 'Starting',
elapsedMs: 0,
estimatedDurationMs: d.estimatedDurationMs ?? 0,
});
// Lock UI based on impact level
if (d.impactLevel === 'BLOCKING' && d.blocksReads) {
lockView(d.viewUuid, 'full'); // Overlay — no reads or writes
} else if (d.impactLevel === 'BLOCKING') {
lockView(d.viewUuid, 'read-only'); // Read-only — disable editing
} else if (d.impactLevel === 'BRIEF') {
lockColumn(d.columnUuid); // Lock just the column header
}
// TRANSPARENT: no UI change needed
break;
}
case 'view.column.schema.progress': {
const d = event.data;
const state = activeMigrations.get(d.columnUuid);
if (state) {
state.progressPercent = d.progressPercent;
state.phase = d.phase;
state.elapsedMs = d.elapsedMs;
// Update progress bar / percentage display
updateProgressUI(d.columnUuid, state);
}
break;
}
case 'view.column.schema.changed': {
const d = event.data;
activeMigrations.delete(d.columnUuid);
if (event.status === 'COMPLETED') {
unlockView(d.viewUuid);
refreshColumnMetadata(d.viewUuid); // Fetch updated column definition
toast.success(`Column "${d.columnName}" updated`);
} else {
unlockView(d.viewUuid);
toast.error(`Migration failed: ${d.message}`);
}
break;
}
}
}Progress Bar UX Recommendations
| Progress Source | Recommended UI |
|---|---|
PG_STAT | Determinate progress bar (0–100%) with phase label |
TIME_ESTIMATE | Determinate progress bar, but label "Estimated" to set expectations |
progressPercent === -1 | Indeterminate/pulsing progress bar with phase label |
When progressPercent reaches 100 but schema.changed hasn't arrived yet, keep showing "Completing..." — the final event confirms actual completion.
Edge Cases
- No progress events: Short migrations (< 1s) will only fire
changing→changedwith no progress in between. The UI should handle zero progress events gracefully. - Progress > estimatedDurationMs: Real migrations can exceed estimates. Don't cap the progress bar at 100% if
elapsedMs > estimatedDurationMs— instead show "Taking longer than expected..." - Multiple columns migrating: Each column tracks independently. The view lock is the union of all active column locks.
- Browser reconnect: On SSE reconnect, check if any columns are still in migration state by calling the REST API. Stale locks should self-clear via TTL.
Task Completion Events (Legacy)
The /sse/task-completions/stream endpoint uses a different payload format (TaskCompletionMessage):
typescript
interface TaskCompletionMessage {
messageId: string;
workspaceId: string;
entityType: string;
entityId: string;
status: "STARTED" | "COMPLETED" | "FAILED";
message?: string;
timestamp: string;
}SSE event names: task-success, task-failure, task-started
Note: Task completions are also broadcast as
workspace.status.changedevents on the workspace stream. The task-completions endpoint is maintained for backward compatibility.
Listening for Events (TypeScript Example)
typescript
function connectToViewSSE(orgSlug: string, wsSlug: string, viewUuid: string, jwt: string) {
const url = `${BASE_URL}/sse/view/${orgSlug}/${wsSlug}/${viewUuid}/stream`;
const source = new EventSource(url, {
headers: { Authorization: `Bearer ${jwt}` }
});
// Connection established
source.addEventListener('connection', (e) => {
console.log('Connected:', JSON.parse(e.data));
});
// All workspace events come as 'message' type
source.addEventListener('message', (e) => {
const event: WorkspaceEvent = JSON.parse(e.data);
// Echo filter
if (event.originClientId === CLIENT_ID) return;
switch (event.eventType) {
case 'data.cell.edited':
updateCell(event.data.rowId, event.data.columnName, event.data.value);
break;
case 'data.row.inserted':
insertRow(event.data.rowData);
break;
case 'view.column.created':
addColumn(event.data.columnName, event.data.columnType);
break;
case 'view.column.deleted':
removeColumn(event.data.columnName);
break;
// ... handle other event types
}
});
source.onerror = (e) => {
console.error('SSE error, will auto-reconnect:', e);
};
return source;
}