Route policy is part of the application contract
Choose declared transport for application endpoints, data collections for local records, and sync for reconciling those records. These are connected but not interchangeable. An application endpoint whose name contains “sync” can still be a product-specific session endpoint rather than cApp's queue protocol.
Use a shared source of route and collection definitions. Expose the appropriate backend/server contracts and select the corresponding contracts in Build. A TypeScript export in your frontend does not modify the immutable policy already packaged on a device.
This complete example defines a small notes collection and the remote endpoints needed to synchronize it. It does not implement an HTTP server or run a request. Save it as notes-contract.ts in your app integration module.
import {
createCappClient, defineCappApplication,
type CappClientOptions, type CappDataContractDefinition,
type CappRoutePolicyDocument,
} from '@capp/app-sdk';
export type Note = { id: string; title: string };
export const notesData: CappDataContractDefinition = {
protocol: 'capp.data-contract.v2', namespace: 'com.example.notes',
collections: [{
id: 'notes', keyField: 'id',
capabilities: ['capp.data.read', 'capp.data.write', 'capp.data.delete'],
schema: {
type: 'object', required: ['id', 'title'], additionalProperties: false,
properties: {
id: { type: 'string', minLength: 1, maxLength: 80 },
title: { type: 'string', minLength: 1, maxLength: 120 },
},
},
revisions: { enabled: true, authority: 'server', monotonic: true },
tombstones: { enabled: true, retainUntilAcknowledged: true },
sync: {
mode: 'bidirectional', pushRoute: '/api/notes/:id', pushMethod: 'PUT',
pullRoute: '/api/notes/changes', pullMethod: 'GET', cursorParam: 'cursor',
ackRequired: true, conflict: 'reject',
},
}],
};
export const notesRoutes: CappRoutePolicyDocument = {
protocol: 'capp.route-policy.v1', appId: 'com.example.notes',
routes: [
{ path: '/api/notes/changes', methods: ['GET'], capability: 'notes.read',
online: true, offline: false, execution: 'remote-only' },
{ path: '/api/notes/:id', methods: ['GET'], capability: 'notes.read',
online: true, offline: false, execution: 'remote-only' },
{ path: '/api/notes/:id', methods: ['PUT', 'DELETE'], capability: 'notes.write',
online: true, offline: false, execution: 'remote-only' },
],
};
export const notesApplication = defineCappApplication({
appId: 'com.example.notes',
requires: { routes: '>=1', data: '>=2', sync: '>=2' },
routes: notesRoutes.routes, data: notesData,
});
export function createNotesClient(environment: Pick<CappClientOptions,
'runtime' | 'runtimeProvider' | 'fetchImpl' | 'baseUrl' | 'onlineState'> = {}) {
return createCappClient({
...environment, application: notesApplication,
routePolicy: notesRoutes, strictWebRoutePolicy: true,
});
}The literal /api/notes/changes route is more specific than /api/notes/:id. Matching normalizes the path and ignores its query/fragment for classification; the actual request still needs its query string. :id, terminal * and terminal :rest* are supported patterns. Next-style [id] folders are not the route-pattern syntax of this SDK. GET does not implicitly declare HEAD.
Choose execution deliberately
The table describes the SDK's ordinary web fallback. An installed routes.request bridge takes over transport and enforces its own compiled policy; the browser client's baseUrl, fetchImpl and offlineHandlers do not replace that native transport.
| Execution | Web-fallback behaviour |
|---|---|
auto | Online: remote. Offline: local handler. No automatic fallback after an online remote failure. |
local-only | Require a declared, implemented local handler; never make a remote request. |
local-preferred | Try an available local handler; a thrown failure can fall back to remote while online. |
remote-only | Require online access and remote admission; never substitute local output. |
remote-preferred | Try remote; a rejected/thrown request can fall back to an available local handler. |
An HTTP 401, 409 or 503 is a resolved Response, not a thrown fetch error: check response.ok yourself. Do not use automatic preference fallback for an effect that may already have occurred before a thrown error. Keep valuable mutations remote-only with stable operation identity and explicit reconciliation.
strictWebRoutePolicy is opt-in and only rejects unmatched /api/ paths when a nonempty policy is supplied. It is not an all-origin firewall. A disabled or excluded matched route is rejected. Classify external URLs separately: a path match does not authenticate the origin. Never strip an external URL to its pathname and accidentally send it through your privileged application transport. Preserve query strings, request bodies and header semantics in any product adapter.
An offline read without a fake backend
This independent rehearsal uses a declared in-memory fixture. It demonstrates fallback selection only: the object is not persistent data and no sync queue is created.
import { createCappClient } from '@capp/app-sdk';
export function createOfflineCatalogue() {
return createCappClient({
runtime: null, onlineState: () => false, strictWebRoutePolicy: true,
routePolicy: { routes: [{
path: '/api/items', methods: ['GET'], online: true, offline: true,
localHandler: 'catalogue-fixture', execution: 'local-only',
}] },
offlineHandlers: {
'catalogue-fixture': () => Response.json(
{ items: [{ id: 'sample', title: 'Offline sample' }] },
{ headers: { 'x-capp-response-source': 'example-fixture' } },
),
},
fetchImpl: async () => { throw new Error('Unexpected remote request'); },
});
}Call await createOfflineCatalogue().routes.request('/api/items'), check ok, and then read json(). The expected source header is example-fixture. Changing the execution to remote-only while offline must reject, not return that fixture.
Local-first collections
Use client.data.collection<Note>('notes') for a declared collection. The TypeScript generic is an editor aid, not validation of incoming JSON. The SDK checks the collection's supported value schema before writes; the installed data service remains the authority for storage and revision behaviour.
The data API example checks that a writer actually exists, writes one note and reads its record metadata. revision is the server revision; localRevision tracks local mutation, and baseRevision identifies the server state the edit was based on. syncState: 'pending' is a saved local edit awaiting reconciliation, not a server-confirmed write.
Start with one note and inspect its value and metadata after reload. Then test a delete: a tombstone is a record of deletion, not permission to discard unacknowledged history. includeDeleted and metadata reads let the application inspect that state. sync: false suppresses enqueueing for that write; it does not certify that a remote copy exists.
Replay and conflicts
The native runtime queues collection pushes and adds operation identity, collection/key and revision headers. Your backend must authenticate the request, validate its body, compare its base revision and atomically apply or replay the intended mutation. Return the saved acknowledgement for a duplicate; do not apply it again.
The acknowledgement and pull-page shapes are contracts your server implements—not a hosted sync service. For this runtime, a required acknowledgement missing from a successful HTTP response leaves the operation queued. An HTTP 409 marks a conflict; other failures remain retryable. Do not send a conflict acknowledgement with HTTP 200: the current runtime's success path does not interpret that as a conflict.
This alpha does not fully validate acknowledgement identity/status against every queued field. A correctly tagged but mismatched acknowledgement must never be generated by your backend; do not use queue removal alone as authoritative proof of business settlement. Keep server-side correlation and reconciliation records.
Pull uses the declared route and cursor and applies returned changes. Inspect conflicts before choosing local, remote or a validated custom resolution. retry(id) can issue another request; it is not a passive status read. resolve() is a data decision, not merely dismissing a banner. Automatic field-level merging and general schema migration are not promised.
Native request authority
The native transport owns protected request headers and install assertions. Do not attach a bearer token or manufacture x-capp-gateway-assertion in product JavaScript to work around a failing native route.
client.security.installIdentity() supplies public enrolment material, not the private signer. User authentication, install trust and paid entitlement are separate backend checks. The optional getAssertion callback is an explicit host/test integration seam; never place a gateway signing secret in browser code.
Acceptance and troubleshooting
| Check | Evidence to retain |
|---|---|
| One online read | Status, validated response and intended origin; not just a resolved promise. |
| Offline-only read | Local fixture/cache source and zero remote calls. |
| Local write and reload | Actual record value, local revision and sync state. |
| Server unavailable | Local edit retained; queue entry and last error visible. |
| Duplicate replay | Same server-side result for the same identity; no second mutation. |
| Conflict and tombstone | HTTP 409 retained for review; deletion not silently resurrected. |
| Pull after reconnect | Cursor, authoritative revisions and explicit conflict handling. |
| Updated app contract | Recompiled package installed; client code alone did not alter native policy. |
Continue with data API, server integration and JSON schemas.