Local-First Architecture
Offline-first data management with real-time synchronization
Local-First Architecture
The platform uses a local-first architecture built on SignalDB with IndexedDB persistence. All data interactions happen locally first, with bi-directional synchronization to the server via REST APIs.
Overview
- Immediate Responsiveness: All reads and writes target local IndexedDB-backed collections. UI updates are instant.
- Offline Functionality: Collections persist in IndexedDB across page reloads. When offline, users can browse cached data and make edits; changes are queued and pushed when connectivity returns.
- Automatic Synchronization: The
SyncManagerhandles incremental pull (viaupdatedAttimestamps) and push (local mutations). - Conflict Resolution: Server-side
baseRevisionchecking detects concurrent edits. A Conflict Resolution UI lets users choose "Accept Theirs" or "Keep Mine". - Per-Entity Sync State: An
EntitySyncStateServicederives per-entity status (synced,localChanges,conflict).
graph TB
subgraph "Client"
UI[Svelte Components] --> Collections[SignalDB Collections]
Collections --> IDB[IndexedDB Persistence]
SM[SmallstackSyncManager] --> Collections
SM --> API[REST API calls]
NS[NetworkStatusService] --> SM
ESS[EntitySyncStateService] --> SM
end
subgraph "Server"
API --> Auth[Auth.js]
Auth --> Endpoints[Generic CRUD Endpoints]
Endpoints --> MongoDB
endCore Services
SignalDbService
Located at packages/client/src/services/signal-db.service.svelte.ts. The central service for data management.
Key responsibilities:
- Creates and manages SignalDB collections with IndexedDB persistence adapters
- Configures the
SmallstackSyncManagerfor bi-directional sync - Tracks initial sync state per collection (
initiallySynced) - Tracks last sync timestamps per collection (
lastSyncedAt) - Manages sync conflicts and their resolution
- Handles configurable polling for opted-in collections (default 5s; drops to 30s heartbeat when Ably is active)
// Getting a collection (creates it on first access, starts sync)
const collection = signalDbService.getCollection<MyEntity>("tenant-abc-types");
// With real-time polling enabled
const collection = signalDbService.getCollection<MyEntity>("tenant-abc-types", { realtime: true });
// Wait for initial data to be available
await signalDbService.isInitiallySynced("tenant-abc-types");
// Adjust polling interval (e.g., reduce to 30s heartbeat while Ably is active)
signalDbService.setPollingInterval(30_000);
// Check whether a collection has been registered
signalDbService.hasCollection("tenant-abc-types"); // boolean
// Check whether a collection has realtime polling enabled
signalDbService.isRealtimeCollection("tenant-abc-types"); // boolean
// Manually trigger a pull sync for a collection
await signalDbService.refreshCollection("tenant-abc-types");
SmallstackSyncManager
Located at packages/client/src/services/smallstack-sync-manager.ts. A subclass of SignalDB's SyncManager that exposes pending change information:
// Get IDs of entities with un-pushed local changes
const pendingIds: Set<string> = syncManager.getPendingChangeIds("my-collection");
// Get detailed pending changes for a specific entity
const changes = syncManager.getPendingChanges("my-collection", "entity-123");
// Get total pending change count across all collections
const count: number = syncManager.getPendingChangeCount();
NetworkStatusService
Located at packages/client/src/services/network-status.service.svelte.ts. Reactive online/offline detection using browser APIs:
import { networkStatusService } from "@smallstack/client";
// Reactive boolean ($state)
networkStatusService.isOnline; // true or false
// Listen for status changes
const unsubscribe = networkStatusService.onStatusChange((isOnline) => {
console.log("Network status changed:", isOnline);
});
EntitySyncStateService
Located at packages/client/src/services/entity-sync-state.service.svelte.ts. Derives per-entity sync state:
import { entitySyncStateService } from "@smallstack/client";
// Returns: "synced" | "localChanges" | "conflict"
const state = entitySyncStateService.getState("entity-id", "collection-name");
// Total pending count
const count = entitySyncStateService.getTotalPendingCount();
Synchronization
How Pull Works
The SyncManager pull handler uses incremental sync:
- Initial sync (
lastFinishedSyncEndis null): Fetches all entities viaGET /api/...?updatedAt=0, returns{ items: [...] } - Subsequent syncs: Fetches only entities modified since the last sync via
GET /api/...?updatedAt=<timestamp>, categorizes intoadded/modified/removedbased onrevisionanddeletedAt - Persistence: The SyncManager itself uses an IndexedDB persistence adapter (
__signaldb_sync__), so sync metadata (snapshots, change tracking, sync operations) survive page reloads
How Push Works
Local mutations are captured by SignalDB's change tracking and pushed via:
- Inserts:
POST /api/...with the full entity - Updates:
PATCH /api/.../entityIdwith{ modifier, baseRevision } - Deletes:
DELETE /api/.../entityId
Conflict Detection
The server checks baseRevision against the current document revision:
- Client sends
{ modifier, baseRevision: N }in PATCH request - Server validates that the document's current
revision === N - If mismatch, server returns HTTP 409 with conflict details
- Client stores the conflict in
signalDbService.conflicts - User resolves via Conflict Resolution UI
Reconnect Behavior
When the network comes back online, networkStatusService.onStatusChange triggers syncManager.syncAll() to:
- Push any queued local changes
- Pull any server-side updates missed while offline
UI Components
OfflineBannerComponent
Fixed banner that appears when offline, auto-dismissing "Back online" notification on reconnect.
ConflictResolutionContainer / ConflictResolutionDialog
Renders active conflicts from signalDbService.conflicts as side-by-side cards with "Accept Theirs" / "Keep Mine" buttons.
EntitySyncBadge
Inline badge showing conflict or pending-changes status for a specific entity. Used in the entity editor.
PendingChangesCounter
Displays total pending change count. Placed in both app layouts.
StaleBadgeComponent
Shows per-collection staleness indicators based on lastSyncedAt timestamps.
Collection Naming & API Resolution
Collections are named using a convention that maps to API paths:
tenant-{projectId}-{typeId} → /api/projects/{projectId}/types/{typeId}/data
tenant-{projectId}-types → /api/projects/{projectId}/types
tenant-{projectId}-applications → /api/projects/{projectId}/applications
The describeCollection() utility from @smallstack/shared handles this mapping.
Offline Auth
When offline, server-side auth hooks (hooks.server.ts) catch network errors and allow the request to proceed with the locally cached session. Auth.js uses JWT strategy with 6-month expiry, so offline sessions remain valid for extended periods.
Real-Time Updates
When an ABLY_API_KEY is configured server-side, the platform uses Ably-driven push notifications to trigger collection syncs instantly when data changes elsewhere — replacing the continuous 5s polling loop with a 30s safety-net heartbeat.
Without Ably (dev environments or missing key), polling remains at the default 5s interval.
Ably-driven sync behaviour
- Realtime collections (
{ realtime: true }) auto-sync silently within ~1.5 s of a remote change. - Manual collections (no
realtimeflag) show a badge on the refresh button so users decide when to reload. - Self-notifications are filtered: changes made in the current browser window never trigger a redundant re-fetch.
- If Ably is unavailable (e.g. token endpoint returns 503), the system continues on polling with no errors.
CollectionSyncNotificationService
Located at packages/client/src/services/collection-sync-notification.service.svelte.ts. Subscribes to the Ably project:{projectId}:sync channel and coordinates with SignalDbService.
// Subscribe when entering a project context (called from layout)
const connected = await syncNotificationService.subscribeToProject(projectId, userId);
if (connected) signalDbService.setPollingInterval(30_000); // reduce heartbeat while Ably is active
// Unsubscribe and restore polling on teardown
await syncNotificationService.unsubscribeFromProject();
signalDbService.setPollingInterval(5000);
// Check whether a manual collection has unseen remote changes
syncNotificationService.hasRemoteChanges("tenant-abc-contacts"); // boolean ($state)
// Clear the badge after a manual refresh
syncNotificationService.clearRemoteChanges("tenant-abc-contacts");
Collections opt in to the polling-based fallback as before:
signalDbService.getCollection("my-collection", { realtime: true });
// or
signalDbService.startRealtime("my-collection");
Best Practices
- Use
getCollection()lazily: Collections sync on first access. Don't pre-register collections you don't need yet. - Wait for initial sync: Use
await signalDbService.isInitiallySynced(name)before rendering data-dependent UI. - Soft deletes: Entities use
deletedAtfield. The sync system tracks deletions via this field. - baseRevision: Always include
baseRevisionin PATCH requests for conflict detection. - Avoid raw fetch for synced data: Use
collection.find()/collection.findOne()for reads. The sync layer handles server communication.
Debugging
In development, you can inspect:
- IndexedDB: Open Browser DevTools → Application → IndexedDB to see collections and the
__signaldb_sync__metadata stores - Console logs: Collection creation and sync operations are logged at
debuglevel - SignalDB DevTools: Uncomment the devtools import in
signal-db.service.svelte.ts