Title: API Event Sourcing Description: How the changes property works in document APIs and when to include change history Tags: api, performance, event-sourcing, developer --- 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/projabc/types/typexyz/data → Hono router: /projects/projabc/types/typexyz/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 HONOHANDLEDPATHS (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 HONOHANDLEDPATHS = ["/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; $unset?: Record; // ... 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 limit above this value are clamped to 200. Use total and limit to 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/projabc/types/typexyz/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 publishedVersionNumber field on the Application document: nextVersionNumber = (publishedVersionNumber ?? 0) + 1. The baseRevision optimistic-concurrency check on the subsequent Application patch ensures only one concurrent publish succeeds. ApplicationVersion documents are immutable — they are never updated or deleted after creation. The source Application document is updated atomically to reflect the new publishedVersionNumber, publishedVersionId, and publishedAt timestamp. serverConfiguration is 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; // ... } 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": { "projabc": 1710000000000, "projxyz": 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