API Event Sourcing
How the changes property works in document APIs and when to include change history
API Event Sourcing
Every document in the platform tracks its full change history via event sourcing. Each document has an optional changes array that records every modification — who made it, when, and what the MongoDB modifier was.
By default, API endpoints exclude the changes array from responses. Include it on demand when you actually need the change history.
→ See also: Local-First Architecture for the broader sync model.
Centralized API routing
All API routes across both the backoffice and applications apps are now served through a centralized Hono router mounted at /api/[...path]. Rather than defining individual SvelteKit +server.ts files for each endpoint, new routes are registered in the shared @smallstack/server package.
How it works
Each app mounts the shared router in src/routes/api/[...path]/+server.ts. Incoming requests are forwarded to the Hono router after stripping the /api prefix — so a request to /api/health is matched against the /health route inside the router:
/api/health → Hono router: /health
/api/projects/proj_abc/types/type_xyz/data → Hono router: /projects/proj_abc/types/type_xyz/data
Adding new routes
Register new routes in packages/server/src/api/router.ts using the shared Hono instance returned by createApiRouter(). Do not create new SvelteKit +server.ts files under apps/*/src/routes/api/ for individual endpoints.
// packages/server/src/api/router.ts
router.get("/my-new-endpoint", myHandler);
Handler files live under packages/server/src/api/routes/.
Authentication middleware
The requireUser middleware in packages/server/src/api/middleware.ts can be applied to any route that requires an authenticated session. It calls ctx.getCurrentUser() and returns 401 Unauthorized if no user is present:
import { requireUser } from "@smallstack/server";
router.get("/protected-route", requireUser, myProtectedHandler);
Bypassing SvelteKit middleware for Hono-handled paths
Certain SvelteKit server hooks (such as currentUser) are skipped for paths that Hono handles directly. The list of bypassed paths is maintained in HONO_HANDLED_PATHS (exported from packages/server/src/api/types.ts). Add any path that Hono handles entirely on its own to this constant to prevent unnecessary SvelteKit middleware execution:
// packages/server/src/api/types.ts
export const HONO_HANDLED_PATHS = ["/api/health"];
The changes property
Each entry in the changes array represents one write operation:
interface EventSource {
userId: string; // Who made the change
timestamp: number; // When (Unix ms)
baseRevision: number; // Document revision before this change
modifier: { // MongoDB update modifier
$set?: Record<string, unknown>;
$unset?: Record<string, unknown>;
// ... other MongoDB operators
};
}
The revision field on the document increments with each change. The platform uses baseRevision to detect conflicts when multiple clients modify the same document concurrently.
API usage
Default (changes excluded)
GET /api/projects/{projectId}/types/{typeId}/data
GET /api/projects/{projectId}/types/{typeId}/data/{id}
Response:
{
"id": "507f1f77bcf86cd799439011",
"name": "Sample Document",
"revision": 3,
"createdAt": 1640995200000,
"updatedAt": 1640995800000
}
Include change history
GET /api/projects/{projectId}/types/{typeId}/data?includeChanges=true
GET /api/projects/{projectId}/types/{typeId}/data/{id}?includeChanges=true
Response:
{
"id": "507f1f77bcf86cd799439011",
"name": "Sample Document",
"revision": 3,
"createdAt": 1640995200000,
"updatedAt": 1640995800000,
"changes": [
{
"userId": "user123",
"timestamp": 1640995200000,
"baseRevision": 0,
"modifier": { "$set": { "name": "Initial Document" } }
},
{
"userId": "user456",
"timestamp": 1640995800000,
"baseRevision": 2,
"modifier": { "$set": { "name": "Sample Document" } }
}
]
}
Project-wide audit trail
For a unified, filterable view of all changes across every entity type in a project, use the dedicated audit trail endpoint. This is the recommended approach for building audit logs and history views rather than fetching includeChanges=true per entity type.
GET /api/projects/{projectId}/audit-trail
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
number | 1 |
Page number (1-based) |
limit |
number | 50 |
Results per page. Maximum 200. |
userId |
string | — | Filter by the ID of the user who made the change |
typeId |
string | — | Filter by entity type ID |
from |
number | — | Timestamp range start in milliseconds (Unix ms) |
to |
number | — | Timestamp range end in milliseconds (Unix ms) |
changeType |
string | — | One of created, updated, or deleted |
Pagination
The response includes pagination metadata alongside the result set:
{
"items": [ /* ChangeFeedItem[] */ ],
"total": 340,
"page": 1,
"limit": 50
}
- Default page size is 50 entries.
- Maximum page size is 200 entries. Requests with
limitabove this value are clamped to200. - Use
totalandlimitto derive the number of pages:Math.ceil(total / limit).
Example: first page with filters
GET /api/projects/proj_abc/audit-trail?page=1&limit=50&changeType=updated&from=1700000000000&to=1710000000000
Response:
{
"items": [
{
"entityId": "507f1f77bcf86cd799439011",
"entityTitle": "Sample Document",
"entityTypeId": "type_xyz",
"entityTypeName": "Contract",
"changeDescription": "changes.feed.updated.single",
"userId": "user123",
"userDisplayName": "Alice Example",
"timestamp": 1709500000000
}
],
"total": 12,
"page": 1,
"limit": 50
}
Access control
The endpoint enforces the same RLS (Row-Level Security) read permissions as other project data endpoints. Only changes on entities the requesting user is permitted to read are included in the response.
Schema and sample-data endpoints
Authentication requirement
The schema and sample-data endpoints require an authenticated session. Unauthenticated requests receive a 401 Unauthorized response.
| Endpoint | Method | Auth required |
|---|---|---|
/api/projects/{projectId}/types/{typeId}/schema |
GET | ✓ Yes |
/api/schemas/projects/{projectId}/types/{typeId} |
GET | ✓ Yes |
/api/projects/{projectId}/types/{typeId}/sample-data |
GET | ✓ Yes |
/api/projects/{projectId}/types/{typeId}/sample-data |
POST | ✓ Yes |
401 response example:
GET /api/projects/proj_abc/types/type_xyz/schema
HTTP/1.1 401 Unauthorized
"Unauthorized"
Ensure you have a valid session before calling these endpoints. In the backoffice, sessions are managed automatically via the authentication flow. When calling these endpoints from external clients or scripts, pass a valid session cookie or authorization header as appropriate.
Application versioning endpoints
Applications support an explicit publish/version workflow. Publishing creates an immutable ApplicationVersion snapshot of the current application state, which can then be pinned to a domain for stable deployments.
Publish an application
Creates an immutable ApplicationVersion snapshot from the current application state. The version number auto-increments per application, starting at 1. Publishing also updates the source Application document with publishedVersionNumber, publishedVersionId, and publishedAt metadata.
Requires update permission on the application (RLS UPDATE permission is checked against the application's access field).
POST /api/projects/{projectId}/applications/{appId}/publish
Request body: none
Response: the newly created ApplicationVersion document.
{
"id": "ver_abc123",
"applicationId": "app_xyz",
"versionNumber": 3,
"publishedBy": "user_456",
"title": { "en": "My App" },
"routes": [ /* ... */ ],
"createdAt": 1710000000000
}
Notes:
- The version number is derived from the
publishedVersionNumberfield on theApplicationdocument:nextVersionNumber = (publishedVersionNumber ?? 0) + 1. ThebaseRevisionoptimistic-concurrency check on the subsequentApplicationpatch ensures only one concurrent publish succeeds. ApplicationVersiondocuments are immutable — they are never updated or deleted after creation.- The source
Applicationdocument is updated atomically to reflect the newpublishedVersionNumber,publishedVersionId, andpublishedAttimestamp. serverConfigurationis included in the snapshot for completeness but is not served to public clients.
List application versions
Returns the full version history for an application, sorted by versionNumber descending (most recent first).
Requires read permission on the application (RLS READ permission is checked against the application's access field).
GET /api/projects/{projectId}/applications/{appId}/versions
Response: array of ApplicationVersion documents, newest first.
[
{
"id": "ver_abc123",
"applicationId": "app_xyz",
"versionNumber": 3,
"publishedBy": "user_456",
"createdAt": 1710000000000
},
{
"id": "ver_abc122",
"applicationId": "app_xyz",
"versionNumber": 2,
"publishedBy": "user_123",
"createdAt": 1709900000000
}
]
Domain version pinning
When assigning a domain to an application, you can optionally pin it to a specific ApplicationVersion using the targetVersionId field on the assign endpoint. When absent (or explicitly cleared), the domain serves the latest published version.
POST /api/projects/{projectId}/domains/{domainId}/assign
Request body:
{
"targetType": "application",
"targetId": "app_xyz",
"targetVersionId": "ver_abc122"
}
To remove a version pin and revert to always-latest behaviour, send targetVersionId as null or omit the key entirely:
{
"targetType": "application",
"targetId": "app_xyz",
"targetVersionId": null
}
When targetVersionId is present and truthy it is stored on the ProjectDomain document via $set. When the key is present in the request body but falsy (null or undefined), the field is removed from the document via $unset, reverting to latest-version behaviour. When the key is absent from the request body altogether, the existing pin (if any) is left unchanged.
When to use includeChanges=true
Only request change history when you actually need it. Documents with long histories can be 50–90% larger with changes included.
Use includeChanges=true when:
- Fetching change history for a specific, known document
- Implementing conflict resolution UI for a single entity
- Debugging sync issues on a particular record
- Running event sourcing analysis scoped to one entity type
Use the audit trail endpoint instead when:
- Building a project-wide audit log or history view
- Filtering changes across multiple entity types simultaneously
- Presenting change history to end users in the backoffice settings
Do not use either for:
- Standard data fetching and display
- SignalDB incremental sync (the sync protocol handles changes separately)
- List endpoints where you only need the current state
Write operations
POST, PATCH, and DELETE operations always return the complete document including the changes array — this is needed for the local-first conflict detection model.
User schema notes
The User document (and its session counterpart LocalAuthUser) tracks recently visited projects via the recentProjects field rather than a single lastProjectId. This field is a map of projectId → timestamp (ms) recording when the user last opened each project, and is capped at 50 entries (oldest entries are evicted first when the cap is reached).
interface User {
// ...
/** Map of projectId → timestamp (ms) tracking when the user last opened each project */
recentProjects?: Record<string, number>;
// ...
}
When a user navigates into a project, a PUT /api/me/last-project call updates this map. The modifier applied to the stored document looks like:
{ "$set": { "recentProjects": { "proj_abc": 1710000000000, "proj_xyz": 1709999000000 } } }
Each write replaces the full recentProjects map with a sorted, capped snapshot, so the change history for this field reflects the complete map state at each point in time — consistent with how all other document fields are tracked via event sourcing.
Related
- Local-First Architecture — how SignalDB uses revision tracking and conflict detection