API Event Sourcing

How the changes property works in document APIs and when to include change history

apiperformanceevent-sourcingdeveloper

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