Skip to content

Server API Reference ​

@korajs/server is the self-hosted sync server. The Production Server guide explains every option and deployment concern; this page lists the API.

createProductionServer(config) ​

One HTTP server for the built app (staticDir), WebSocket and HTTP sync (syncPath), custom routes, health, metrics, the dashboard and backups.

typescript
const store = createSqliteServerStore({ filename: './kora-server.db' })
await store.setSchema(schema)

const server = createProductionServer({
  store,
  staticDir: './dist',
  syncPath: '/kora-sync',
  httpRoutes: [],
  operationalAuth: { adminToken: process.env.KORA_ADMIN_TOKEN },
  syncOptions: { schemaVersion: schema.version },
})
const url = await server.start()
OptionDefault
storerequired
portPORT environment variable, else 3001
staticDir'./dist' (the SPA shell is served for unknown paths)
syncPath'/kora-sync'
syncOptionsKoraSyncServerConfig without store, port, host, path
httpRoutes[]: { path, handle(request) }, mounted before static files
operationalAuth{ adminToken?, metricsToken?, backupToken?, allowPublic? }; endpoints without a token are disabled under NODE_ENV=production (403) unless allowPublic, public otherwise
crossOriginEmbedderPolicy'credentialless'
trustProxyfalse: request.ip is the socket address unless the proxy is trusted
maxRequestBodyBytes1 MiB for httpRoutes bodies (larger requests get 413)
maxBackupBytes256 MiB for /__kora/backup/import

The handle (ProductionServer) has start() (resolves to the URL), stop(), kora (the route context), getLiveBlobRefs(), refreshScopes(userId), revalidateSessions(), forceFullResync({ userId?, nodeId? }) and onOperationsCommitted(handler) (see the KoraSyncServer methods below). /health is always public; /__kora/* endpoints use the matching token as Authorization: Bearer <token>.

KoraSyncServer ​

new KoraSyncServer(config) (or createKoraServer(config)) runs sync alone, standalone with start() or attached to your HTTP server.

MethodDescription
start(wsServerImpl?) / stop()Standalone WebSocket server on port.
handleWebSocket(ws) / handleConnection(transport)Attach a connection from your own server.
handleHttpRequest(request)HTTP long-polling: map method, body, contentType, ifNoneMatch, authorization and the x-kora-session header (sessionId). The handshake response carries a server-issued session id; every request is authenticated and must match the session's user and device. Idle sessions close after httpSessionIdleTimeoutMs (2 minutes).
terminateSessions({ userId?, deviceId?, code? })Ends matching live sessions (AUTH_REVOKED by default); returns how many.
revalidateSessions()Re-checks every live session's credential and scope now (also runs every sessionRevalidationIntervalMs).
refreshScopes(userId)Re-checks one user's live sessions now, after a membership change; a session whose grant changed ends with a retriable SCOPE_CHANGED and reconnects with the new grant. Returns how many ended. This process only.
revalidateAccessSessions()Ends every live session whose access rules are no longer in force (the store is rebuilding its membership index, or rebuilt it since the session started) with a retriable ACCESS_RULES_CHANGED; each reconnects and is re-scoped. Runs on every delivery poll and after /__kora/backup/import; call it after store.importBackup(...) in your own code.
releaseNodeClaim(nodeId)Releases a device node id so the next principal can claim it.
forceFullResync({ userId?, nodeId? })Makes every device of a user, or one device node, download everything again: its next handshake restarts delivery from 0 (the path a restored server uses), and this instance's live sessions end with a retriable RESYNC_REQUIRED so they reconnect now. Stored in the server store (request tokens come from a counter there, shared by every instance), so a device that is offline or on another instance resyncs when it next connects, once per request in each delivery view (scope and query subsets) it streams under. A custom store must implement requestFullResync, getFullResyncState and completeFullResync together, or it refuses with FULL_RESYNC_UNSUPPORTED. Use it to repair devices that kept data a fixed bug should have removed (re-scoping does not: it judges the device's own values). Watermark clients only (1.0.0-beta.5 and later). Returns how many live sessions ended.
applyLocalOperation(...), relayServerOperations(operations)Server-authored writes and their fan-out (prefer the route context). Both report to onOperationsCommitted.
onOperationsCommitted(handler)Runs handler after every committed batch on this instance; returns an unsubscribe function. See Reacting to committed writes.
getKoraContext()The route context.
getStatus(){ running, connectedClients, port, totalOperations, uptime, version, schemaVersion, connectedNodeIds, peakConnections, connectionsTotal }.
getConnectionCount(), getMetricsCollector(), getLogger(), getLiveBlobRefs()Introspection.
authoritativeNodeIdsNode ids whose writes win merge('server-authoritative') fields.

KoraSyncServerConfig fields (auth, emitter, schemaVersion, supportedSchemaVersions, operationTransforms, encryption, validateOperation, onOperationsCommitted, size and rate limits, heartbeats, sessionRevalidationIntervalMs, blob callbacks, logger, ...) are listed with their defaults in Server options.

Stores ​

StoreCreate
SQLitecreateSqliteServerStore({ filename?, ...identity }) (SqliteServerStore)
Postgresawait createPostgresServerStore({ connectionString, ...identity }) (PostgresServerStore)
Memory (tests)new MemoryServerStore(options?)

Identity options (nodeId, instanceId, authoritativeNodeIds, revokedAuthoritativeNodeIds) are described in Server identity.

Every store implements ServerStore:

MethodDescription
setSchema(schema, { operationTransforms? })Creates one table per collection, folds existing operations into it, and enables constraint, relation and scope checks. Call it before serving clients.
queryCollection(collection, { where?, orderBy?, orderDirection?, limit?, offset?, includeDeleted? })Materialized records (where is exact match).
findRecord(collection, id)One record, or null when missing or deleted.
countCollection(collection, where?)Count.
materializeCollection(collection)Every record (from the log when no schema is set).
applyRemoteOperation(op, options?)Ingest path used by the sync server. With authorize and previewResult, the store folds the operation onto the record in the same critical section and passes the resulting record to authorize as context.resulting (access rules judge it). A custom store without it limits atomic intents in access-ruled collections to 4 per operation.
getMaxDeliverySequence(), getOperationsAfterDelivery(after, limit)The delivery sequence behind gap-free downloads.
exportBackup(), importBackup(data, merge?)The operation log plus the users' encryption key records (merge: false replaces the log, true merges into it); an import reconciles key records (replace mode takes the backup's; merge mode adds missing ones and advances an older revision of the same ring). Used by /__kora/backup/* and kora backup. With access rules, call server.revalidateAccessSessions() after an importBackup you run yourself (the backup route does).
getMembershipIntervals(userId), getAccessIndexState(), isAccessIndexReady(), accessIndexGeneration()The membership index access rules are judged against: a user's intervals; the index state (frontier, generation, rebuilding); false while this store rebuilds the index or another instance's restore is rebuilding it; the generation this instance last saw. A custom store needs the first two for access rules (ACCESS_RULES_UNSUPPORTED_STORE otherwise). The other two are optional: without them, sessions follow the generation and rebuilding state getAccessIndexState() reports at every delivery poll, so a rebuild reaches them within one poll instead of at their next send.
refoldOutdatedRecords(limit?)Optional. Re-folds a batch of records an instance on another fold plan wrote (a rolling deploy that changed transforms or merge rules), when this instance runs the plan the database records; returns how many. The sync server calls it on every delivery poll. The built-in SQLite and Postgres stores implement it.
getEncryptionKeyRecord, putEncryptionKeyRecord, getEncryptedKeyIds, listEncryptionKeyRecordsThe key service's storage (compare-and-set). A custom store without the first two makes the key service answer unsupported; see Sync Encryption.

Server writes go through the same fold as devices; never write materialized tables directly. Use the route context instead.

Route context ​

server.kora, request.kora in custom routes, and context.kora in validators give trusted, scoped access that runs every write through the sync pipeline (validation, constraints, relations, fold, fan-out):

MethodDescription
apply({ collection, type, recordId?, data? }, { scope? })One mutation. Resolves to { ok: true, operation, record } or { ok: false, code, message, retriable }.
applyConditional({ collection, id, if?, update?, also?, reject?, idempotencyKey? }, { scope? })Reads the target, checks the predicate ($eq, $ne, $lt, $lte, $gt, $gte, $in) and commits the update plus every also mutation only if it holds, at most once per idempotencyKey. All or nothing, on every store.
transaction(async (tx) => mutations | tx.reject(code, message), { scope? })Read, decide and write as one step: no other write commits between the reads tx makes (findById, query, roleOf) and the commit, and the mutations commit together or not at all. Resolves like applyConditional without idempotent.
query(collection, { ...queryOptions, scope? }) / findById(collection, id, { scope? })Reads.

scope ({ collection: { field: value } }) applies a caller's scope exactly like a sync session: apply refuses a write whose resulting record falls outside it, and reads only return records inside it. Omit it only for routes that are public by design.

typescript
const result = await server.kora.apply(
  { collection: 'todos', type: 'update', recordId: 'todo-1', data: { completed: true } },
  { scope: { todos: { userId: 'user-1' } } },
)
if (!result.ok) console.warn(result.code, result.message)

Transactions ​

kora.transaction is for decisions that must hold when they commit: the last seat, a stock level, an invitation that must still be valid. Every write waits while a transaction decides, on every server instance sharing the database, so keep the decision short. The decision may run more than once (on SQLite it is made again when another process committed meanwhile), so it must have no effect besides the result it returns. Return writes from the decision instead of starting them inside it: kora.apply or server.access.grant called there throws RouteTransactionReentryError (use server.access.prepareGrant(tx, ...) to add a membership change to the transaction).

typescript
const booking = await server.kora.transaction(async (tx) => {
  const seat = await tx.findById('seats', 'seat-1')
  if (!seat || seat.takenBy) return tx.reject('TAKEN', 'This seat is taken.')
  return [
    { collection: 'seats', type: 'update', recordId: 'seat-1', data: { takenBy: 'user-1' } },
    { collection: 'bookings', type: 'insert', data: { seatId: 'seat-1', userId: 'user-1' } },
  ]
})
if (!booking.ok) console.warn(booking.code, booking.message)

Each mutation is checked like any other write (schema, constraints, relations, scope), against the state the whole transaction leaves: a parent inserted with its child passes, two inserts with one unique value are refused, and children removed in the same transaction do not block deleting their parent. tx.roleOf(userId, group) reads the access index (see Access Rules). A custom ServerStore without transact runs the decision and the writes one after the other, without isolation.

Reacting to committed writes ​

onOperationsCommitted(operations, context) runs once per batch of operations after they are durably committed. Use it for work that must follow a write that really happened: a share was revoked, so re-check live sessions with server.revalidateSessions() and clean up your own rows. validateOperation cannot do this, because it runs before the commit and the write may still be refused.

typescript
const committedServer = createProductionServer({
  store,
  syncOptions: {
    schemaVersion: schema.version,
    onOperationsCommitted: (operations, { source, auth }) => {
      if (operations.some((op) => op.collection === 'todos' && op.type === 'delete')) {
        console.log(`todos deleted by ${source === 'sync' ? auth?.userId : 'the server'}`)
      }
    },
  },
})
// More handlers, registered later; each call returns its unsubscribe function.
const stopListening = committedServer.onOperationsCommitted(async () => {
  await committedServer.revalidateSessions()
})
Write pathcontext.sourcecontext.auth / context.sessionId
Device uploads over WebSocket or HTTP long-polling'sync'The session's AuthContext and id
server.kora.apply, applyConditional, transaction, server.access, the @korajs/spaces routes, writes a validator makes through context.kora, applyLocalOperation, relayServerOperations'server'null / null
Backup restore (/__kora/backup/import, store.importBackup)not called
  • One call per batch, after the commit. An upload batch, one kora.apply, one transaction or one applyConditional set is one call (on a custom store without transact, which commits a set one mutation at a time, one call per mutation). The referential side effects (cascade, set-null) and cross-record corrections a write causes are in the same call, after it. A batch that stops part way (a store error, the session ending, a cascade or correction failing after the write committed) still reports and delivers everything it committed before the failure; a kora.apply whose follow-up failed rejects, but its committed write is reported.
  • Only what was committed. Refused, ignored (validateOperation returned ignore) and duplicate operations never appear, and a batch in which nothing committed makes no call.
  • Operations as stored. Each handler gets its own deep, frozen copy of the operations (in commit order) and of the context, auth included: neither can be changed, and nothing a handler does reaches the session's credential, other devices or the next handler. The copy is a structuredClone; when auth holds something that cannot be cloned (a function in metadata, say), only that is left out (dropped from an object, Map or Set, undefined in an array), and Dates, Maps, Sets and typed arrays elsewhere are kept as they are. A device on an older schema version wrote its operation in that version (op.schemaVersion), and an end-to-end encrypted operation carries its data only in op.encrypted. To act on the current record, read it with server.kora.findById.
  • Never in the way of the write. The handler is not awaited, so calls can overlap and are not ordered across sessions. A handler that throws or rejects is logged as operations.commit_hook_failed, counted in the error metrics and emitted on the server's emitter as server:commit-hook-failed; the commit, the acknowledgment and the delivery to other devices are unaffected. Keep synchronous work short: it runs on the server's event loop.
  • This instance only. With several server instances, each batch is reported on the instance that committed it. A process that stops between the commit and the call does not report it, so for work that must not be missed, also reconcile periodically (as a sweep on start-up).
  • A restore is not reported. It replaces or merges the whole log; re-derive whatever your handler maintains after one.

Authentication ​

An AuthProvider has authenticate(token) returning an AuthContext or null, and optional onRevoke(listener).

AuthContext fieldDescription
userIdRequired.
scopesPer-collection filters for both directions; collections it omits are denied.
downlinkScopes / uplinkScopesSeparate read and write grants (override scopes).
metadataFree-form.
anonymousSet by MixedAuthProvider for token-less sessions.
expiresAtCredential expiry (ms); the session ends when it passes.
accessGrantsCapability grants under access rules: [{ group, role, expiresAt? }], roles in groups this credential holds without a membership (a share link). See Capability grants.
ProviderDescription
NoAuthProviderDefault without auth: every client is userId: 'anonymous' and shares one data space.
TokenAuthProvider({ validate })Your own token check.
KoraAuthProvider({ tokenValidator, userLookup, deviceTracker?, resolveScopes? })Bridges @korajs/auth stores. createKoraAuthServer().auth is the simpler path (see Authentication).
MixedAuthProvider({ primary, anonymousScopes, anonymousPrefix? })A token is judged by primary (an invalid one is refused, not downgraded); only a client with no token is anonymous, with exactly anonymousScopes.

Scope helpers: resolveSessionScopes, resolveSessionScopeGrant, normalizeScopeMap, operationMatchesScopes, authorizeUplinkWrite, claimScopes. A grant value that is missing fails closed (ScopeRequiredError, INVALID_SCOPE_PREDICATE); predicates are limited to DEFAULT_MAX_SCOPE_PREDICATE_VALUES values (ScopePredicateLimitError).

Operation validation ​

validateOperation(operation, { auth, kora }) runs at ingest for every client operation, after the built-in checks (id, timestamp, size, rate, scope) and before the fold. Return { action: 'accept' }, { action: 'reject', code, message, retriable? } or { action: 'ignore' } (handled out of band, for example by writing a derived record through kora). See Server-side Validation.

typescript
import type { OperationValidator } from '@korajs/server'

const validateOperation: OperationValidator = (operation, context) => {
  if (!context.auth && operation.collection !== 'responses') {
    return { action: 'reject', code: 'SCOPE_VIOLATION', message: 'Sign in to edit this.' }
  }
  return { action: 'accept' }
}

Rejections ​

A refused operation reaches its author as sync:operation-rejected with { code, message, retriable } and stays in app.sync.getRejectedOperations(). RETRIABLE_REJECTION_CODES contains RATE_LIMIT; isRetriableRejection(code) tests membership. Every other code is permanent, for example SCOPE_VIOLATION, CONSTRAINT_VIOLATION, RESTRICTED, INVALID_OPERATION_ID, OPERATION_TOO_LARGE and NODE_ID_MISMATCH. The Error Codes reference lists them all.

Awareness relay ​

AwarenessRelay forwards presence between sessions without storing it (addClient, hasClient, removeClient, handleUpdate, getClientCount, clear). handleUpdate(sessionId, message, audience?) takes an optional AwarenessAudience, (targetSessionId) => boolean, deciding who may see the state; without one the state stays in the sender's partition (addClient's fourth argument). KoraSyncServer runs one per server, relays only between sessions that completed a handshake, and delivers a state whose cursor names a record to the sessions whose download scope contains that record (a state without a cursor: identical download scope, never anonymous sessions). See Presence.

Logging ​

createDefaultLogger(), createJsonLogger(), createPrettyLogger() and createSilentLogger() build a Logger for the logger option.