Skip to content

Upgrading to 1.0.0-beta.13 ​

1.0.0-beta.13 is the security, data-safety and convergence release that follows 1.0.0-beta.12. It is breaking by design: beta.12 let a client of a multi-user sync server read and modify other users' data, could silently lose writes, could leave devices and the server disagreeing about a record forever, and its end-to-end encryption never worked across devices. This page takes an app from beta.12 to beta.13 step by step. The complete list of changes is in the release notes.

Order: servers first, then clients. A beta.13 server accepts beta.12 clients for this release (with a deprecation warning and the exceptions below). A beta.13 client against a beta.12 server syncs plaintext only, without operation verification or encryption: never run that way.

0. Before you start ​

  • Back up the server. kora backup create --url https://sync.example.com --token "$KORA_BACKUP_TOKEN" (see Backup and Restore). Keep a copy of the database files too.
  • Schedule the server's first start. On Postgres it converts sequence columns to BIGINT, an exclusive table rewrite done once, then folds every record once (in 500-record transactions, safe during a rolling deploy). Plan a window proportional to your operation log.
  • Read the custom code you own. Custom AuthProviders, ServerStores, TokenRevocationStores, OrgStores, HTTP sync endpoints, custom merge resolvers and schema transforms all have contract changes below.
  • Install the beta tag everywhere: pnpm add korajs@beta @korajs/server@beta .... Upgrade korajs and every @korajs/* package of an app or server together (they release together; do not mix releases in one process).

1. Upgrade the sync server ​

Give the server the schema and an auth provider ​

The server now decides who may sync and write what. It needs your schema to authorize writes against the stored record, to enforce scopes, relations and constraints, and to fold records:

typescript
import { createKoraAuthServer, createSqliteUserStore } from '@korajs/auth/server'
import { createProductionServer, createSqliteServerStore } from '@korajs/server'

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

const authServer = createKoraAuthServer({
  // Persistent: an in-memory user store is refused under NODE_ENV=production.
  userStore: await createSqliteUserStore({ filename: './auth.db' }),
  jwtSecret: process.env.KORA_AUTH_SECRET,
  // Schema-scoped collections bind to the verified { userId } automatically.
  // Every other scope key must come from the server:
  scopeValues: async ({ userId }) => ({ orgId: await orgOf(userId) }),
})

const server = createProductionServer({
  store,
  httpRoutes: [{ path: '/auth', handle: authServer.handleRequest }],
  trustProxy: 1, // only if a reverse proxy sets X-Forwarded-For (hop count or CIDR list)
  syncOptions: { auth: authServer.auth, schemaVersion: schema.version },
  operationalAuth: {
    adminToken: process.env.KORA_ADMIN_TOKEN,
    backupToken: process.env.KORA_BACKUP_TOKEN,
  },
})

What changed and what to do:

beta.12 behaviourbeta.13What to do
The client's scope decided what it syncedThe server grants scopes; the client can only narrow themSupply non-userId bindings with scopeValues (or a full resolveScopes). A custom AuthProvider must return scopes for every scoped collection, or sessions are refused SCOPE_REQUIRED. A null/undefined grant value fails closed (INVALID_SCOPE_PREDICATE).
A schemaless server with a token providerSyncs unscoped, with a one-time warningMulti-tenant servers must call store.setSchema(schema) or return explicit scopes.
Writes judged on client-sent previousDataJudged on the stored and the resulting recordOwnership transfer (moving a record out of the writer's scope) goes through a server route (request.kora.apply without a scope, or an admin path).
Any node id in an uploadOperations must come from the session's own node; node ids are claimed per userNothing for beta.13 clients. See beta.12 devices below.
In-memory auth stores everywhereRefused in production (IN_MEMORY_AUTH_STORE)createSqliteUserStore / createPostgresUserStore (their revocation storage is used automatically), or allowInMemory: true deliberately.
X-Forwarded-For trustedTrusted only from trustProxySet trustProxy behind a proxy, or request.ip is the proxy's address.
Token in the WebSocket URLSent in the handshakeA proxy that needs it in the URL: tokenInUrl: true on a WebSocketTransport you build.

Auth contracts ​

AreaWhat to do
TokenRevocationStoreCustom stores implement consume, isConsumed and the revocation cut-offs; isDeviceRevoked is removed.
OrgStoreCustom stores implement listMyInvitations(userId) and revokeInvitation(orgId, id). Invitations require the verified invited email.
OAuthCallbacks need the flow-binding cookie set at the start of the flow (same browser). Linking starts at POST /auth/oauth/:provider/link/start.
MFAUsers with MFA get MfaRequiredError at sign-in; complete it with verifyMfa(mfaToken, { code }).
Password resetConfigure onResetRequested (tokens are returned only with exposeTokenForDevelopment, never in production). Wire onPasswordChanged: authServer.revokeAllForUser and new AdminApi({ userStore, revokeAllForUser: authServer.revokeAllForUser }).
WebhooksSignatures are t=…,v1=…; verify them with the timestamp tolerance. Private targets are refused.
PasskeysUser verification is required by default.
RevocationDevice and user revocation end live sessions on every instance. Nothing to wire when the sync server uses authServer.auth; for a wrapped provider call authServer.bindSyncServer(syncServer).

An HTTP long-poll endpoint ​

createProductionServer serves WebSocket sync. If you mapped HTTP long-polling yourself with handleHttpRequest, every request must now carry the credential and the server-issued session id, and the response must return that id:

typescript
import { createServer } from 'node:http'
import { createKoraServer, createSqliteServerStore } from '@korajs/server'

const sync = createKoraServer({ store: createSqliteServerStore({ filename: './kora-server.db' }), auth })

createServer(async (req, res) => {
  const chunks: Uint8Array[] = []
  for await (const chunk of req) chunks.push(chunk as Uint8Array)
  const header = (name: string): string | undefined => {
    const value = req.headers[name]
    return Array.isArray(value) ? value[0] : value
  }
  const response = await sync.handleHttpRequest({
    method: req.method === 'POST' ? 'POST' : 'GET',
    body: chunks.length > 0 ? Buffer.concat(chunks) : undefined,
    contentType: header('content-type'),
    ifNoneMatch: header('if-none-match'),
    authorization: header('authorization'), // checked on EVERY request now
    sessionId: header('x-kora-session'), // issued by the handshake response
  })
  res.writeHead(response.status, {
    ...response.headers, // carries x-kora-session after the handshake
    'access-control-expose-headers': 'x-kora-session', // for cross-origin clients
  })
  res.end(response.body)
}).listen(3001)

Old HTTP clients must be upgraded: they do not send the session header.

Schema transforms, limits and new options ​

  • Transforms run at fold time on every replica. Operations are stored and synced as written; pass the same operationTransforms to the server (syncOptions.operationTransforms, or store.setSchema(schema, { operationTransforms })) and to createApp (sync.operationTransforms). Transforms must be pure and may rewrite only data, previousData, atomicOps and schemaVersion. Never retire a transform while operations of its source version are in a log: the server and devices refuse to start (OPERATION_TRANSFORM_MISSING) rather than fold those operations as absent.
  • Limits: WebSocket messages over 32 MiB (maxMessageBytes), batches over 1000 operations (maxOpsPerBatch), operations over 256 KiB (maxOperationBytes), and blob traffic (blobLimits) are refused. Raise them on the server and set the same store.maxOperationBytes in createApp.
  • New server options (defaults are production-safe): heartbeatIntervalMs, appHeartbeatIntervalMs, handshakeTimeoutMs, maxBufferedBytes, deliveryHighWaterBytes, perMessageDeflate, maxRequestBodyBytes, maxBackupBytes, maxConnections, maxOpsPerMinutePerUser, encryption: { required, allowPlaintextMigration }. See Server options.
  • Server identity: the store option nodeId is deprecated. The server authors under kora:server:<deploymentId>:<instanceId>; a configured nodeId becomes a legacy authoritative id so earlier server decisions keep winning. On Postgres set a distinct instanceId per instance only if you need a stable one. Removing an id from authoritativeNodeIds does not revoke it: use revokedAuthoritativeNodeIds. See Server identity.
  • Protobuf is no longer negotiated (clients speak JSON); ProtobufMessageSerializer is an explicit choice on both ends, and DynamicProtobufSerializer is removed.
  • Custom ServerStores implement the key service (getEncryptionKeyRecord, putEncryptionKeyRecord, and getEncryptedKeyIds / listEncryptionKeyRecords) to support encryption, and their exportBackup() / importBackup() carry key records.

Database migrations generated by an earlier kora migrate ​

A migration generated by beta.12's kora migrate that rebuilds a table (_kora_mig_<collection>_new) is refused on Postgres, where it never worked, and on SQLite it dropped Kora's internal columns, foreign keys and indexes. Regenerate it: restore kora/schema.snapshot.json to the version it migrates from, delete its files and run kora migrate again. New migrations use --kora:evolve-table and --kora:relax-value-domain steps that change only what the schema changed (CLI reference).

What the first start does ​

Automatic, idempotent, and logged:

  • New tables and columns: scope_snapshot, blob_owners, node_claims, kora_server_meta, operation_resolutions, sequence_pairs, operations_quarantine, kora_encryption_keys, the fold state. Postgres sequence columns become BIGINT.
  • Scope snapshots are backfilled, legacy authoritative ids recorded, and the log is scanned once for integrity: unreadable rows move to operations_quarantine instead of being folded (store.getLogIntegrityReport()).
  • beta.12 enum CHECK and NOT NULL table constraints are relaxed once (the value domain is enforced by validation now, so a schema upgrade can add enum values or make a field optional).
  • Every record is re-folded from its log with the beta.13 merge semantics. Later starts re-fold only records whose fold state is missing or stale.

beta.12 devices after the server upgrade ​

  • Protocol 1 is accepted, deprecated, in beta.13 and beta.14 (session.protocol_deprecated in the server log). A later release refuses it, announced in its release notes: upgrade clients now. (beta.14 keeps it so that a device still running a cached beta.12 bundle can upload its queued writes through the automatic handover below.)

  • Signed-in devices: beta.12 recorded no node claims, so every node with history is ownerless and a signed-in device is refused NODE_ID_CLAIMED. What happens next depends on how the client gets its node id:

    • @korajs/auth apps (authClient: createKoraAuthSync(...), the default in every sync template): the node id is the signed-in device id and cannot change. Without a server step, these devices keep reconnecting (status offline; beta.13 clients also emit store:persistence-error with code NODE_ROTATION_FAILED) and their offline writes never upload, on beta.12 and on beta.13 clients alike. From 1.0.0-beta.14 this is automatic: the server claims an ownerless node for the signed-in user whose verified device id equals the node id (deviceNodeHandover, on by default), in one atomic step, and the queued offline writes upload. Upgrading straight to beta.14 needs no script. On a beta.13 server, bind each node to the user who owns that device with a one-time script, with the server stopped. Either way, if your beta.12 server kept accounts in memory (no userStore, as in every beta.12 template), give it a persistent user store first: the procedure covers that case.

      One beta.12 case is not covered by the device id: a user who signed up and wrote without reloading the page. beta.12 opened the store before anyone was signed in, so those writes were authored under a random node, not the device id. From beta.14 the upgraded client registers that node (it used to leave its queued writes in the queue, never uploaded and never reported) and holds its writes as unassigned (status.heldNodes): beta.12 recorded no author, so only the app can say whose they are. Assign them (app.sync.assignHeld, or sync.unassignedWrites: 'assign-to-first-user' in single-user apps) and the writes the old server never stored are re-authored under a fresh node of the user's and upload; what it did store is not repeated.

    • Token apps (sync.auth): a beta.13 client moves to a fresh node automatically and re-uploads what the old server never acknowledged. A beta.12 client cannot; upgrade the client with the server, or release its node with server.releaseNodeClaim(nodeId) (the next principal to present a released node gets it).

    Procedure and script: Upgrading a beta.12 server database.

  • Anonymous devices: allowLegacyAnonymousClaims (default true in beta.13 and beta.14, false from a later release) re-issues pre-claims nodes with a warning. Set it to false once every client is on beta.13.

  • Encrypted beta.12 clients cannot sync with a beta.13 server: their per-device ciphertext was never readable elsewhere (see Encryption below).

  • Mixed fleets: a beta.12 client keeps beta.12 merge semantics for concurrent edits until it upgrades, and applies a scope entry as a plain insert. Its first beta.13 open re-folds every record and it converges.

2. Upgrade the clients ​

Code changes ​

typescript
const app = createApp({
  schema,
  store: {
    // Writes are refused (StorageDurabilityError) when no durable storage opens.
    // allowNonDurable: true accepts in-memory storage knowingly.
    maxOperationBytes: 256 * 1024, // keep equal to the server's maxOperationBytes
  },
  sync: {
    url: 'wss://sync.example.com/kora-sync',
    autoConnect: true,
    operationTransforms: [], // the same list as the server's
    encryption: {
      enabled: true,
      // Foreign keys of cascade / set-null / restrict relations must be cleartext,
      // or createApp throws SealedRelationFieldError.
      cleartextFields: { todos: ['ownerId', 'projectId'], projects: ['ownerId'] },
    },
  },
})

await app.ready
await app.encryption?.unlock(passphrase)

// Writes made on a never-synced database before the app knew the signed-in user are held:
for (const node of (await app.sync?.getHeldOperations()) ?? []) {
  if (node.reason === 'unassigned') await app.sync?.assignHeld(node.nodeId, 'current-user')
}
ChangeWhat to do
Durable storageIf OPFS and IndexedDB both fail, writes are refused (StorageDurabilityError, store:durability-lost) instead of silently living in memory. Show a blocking state, or set store.allowNonDurable: true.
Value domaint.timestamp() takes integer milliseconds in the Date range; t.number() refuses ±Infinity; json/object values nest at most 64 levels and may not hold __proto__; values with no JSON form (Map, Set, class instances, NaN, cycles) throw SchemaValidationError (NON_CANONICAL_VALUE). update(id, { field: undefined }) clears the field.
where valuesundefined adds no condition (it used to match nothing); null means IS NULL; NaN/Infinity throw QueryError. Check filters built from optional UI state.
Merge semanticsArrays are multisets (duplicates kept, one removal removes one copy), objects merge per top-level key, counter/max/min/append-only fold over every write. Review custom resolvers: they are called once per write in HLC order (local = merged so far, remote = the write's value, base = its previousData). See Conflict Resolution. To compare with beta.12 for this release only: experimental: { legacyMerge: true }.
EncryptionUse sync.encryption: { enabled: true } with app.encryption.unlock(passphrase) (or key). The server must be beta.13. List enforced foreign keys and scope keys in cleartextFields. See Sync Encryption.
Held writesstatus.heldOperations and status.heldNodes report them; assign or discard them, or set sync.unassignedWrites: 'assign-to-first-user' in single-user apps.
Out-of-scope editsA local write outside the session's upload scope is reported (sync:operation-rejected, OUT_OF_UPLINK_SCOPE) and kept in getRejectedOperations(). Client-side scope filtering is removed.
app.storageA collection named storage is reached through app.collections.storage.
Enum .transitions()A single enum field with .transitions() now defines the collection's state machine (mode reject), enforced for transaction writes too.
SSRcreateApp is inert without window (ServerRenderingAppError on app.ready). Node programs that need a database pass ssr: false or store.adapter: 'better-sqlite3'. See Server Rendering.
@korajs/auth clientAuthSyncState.token may be null while authenticated-offline; AuthBoundKoraProvider gains a locked state. Network errors no longer sign users out.
Scope helpersoperationMatchesScope and friends ignore previousData unless you pass { includePreviousData: true }.
Blob referencesA blob field may reference only content the writer can read or uploaded (uploads before the reference are automatic). Bytes uploaded before the upgrade have no owner: existing references keep working, new references need a re-upload.
Backup filesBackups exported by beta.12 or earlier (format 1) are refused, and app.importBackup does not throw: it returns { success: false, errorCode: 'BACKUP_FORMAT_OUTDATED' }. Check the result, and on that code run await convertBackupV1(bytes) (exported by korajs) and import again. Code that ignores the result restores nothing without noticing. From beta.14 importBackup converts a format-1 file itself.
Rich text sizeA rich-text save writes the whole document state, so with the default 256 KiB maxOperationBytes saves fail (OPERATION_TOO_LARGE) once a document's text passes about 100 KB. Raise maxOperationBytes on the server and store.maxOperationBytes on the client to the same value.
json values written as stringsServers before beta.13 decoded one JSON-string layer of t.json() / t.object() values when they built rows; beta.13 stores exactly what the operations wrote. A client that wrote such a field as a string (fields: '[]') or double-encoded it now reads back a string where the old server returned an object or array. Kora does not rewrite these values, because a string is also a valid json value. From beta.14, findJsonStringValues(store) (from @korajs/server) lists the fields and rows that hold encoded JSON strings; fix them with server-authored updates through server.kora.apply once you know the shape your app expects, and fix the client code that wrote strings.
Constraint whereOperators in a constraint's where are not supported: values match by plain equality, so { status: { $ne: 'draft' } } matches no record and the constraint is not enforced as written. Use equality values only, or enforce the rule in a server route. From beta.14, defineSchema refuses such a constraint with a SchemaValidationError naming the field.
Removed internalsLocalMutationHandler.commitTransaction and the TransactionBufferedEntry / TransactionCommitBatch / TransactionCommitResult types.
kora deployRender and Docker are "coming soon" and refused; use Fly.io, Railway or AWS.

What the first open does ​

On the first open with beta.13, each device, in its own transactions:

  • repairs duplicate sequence numbers and replaces old index names;
  • moves OPFS databases into their own per-database pools (store:storage-migrated);
  • relaxes beta.12 enum CHECK / NOT NULL table constraints;
  • makes beta.12 undefined clears explicit and re-materializes every record from its log (store:rematerialized): devices that diverged under beta.12 converge, so visible values can change there;
  • re-uploads its own history once (the server deduplicates it).

A tab still running beta.12 makes the upgraded tab wait (store:storage-blocked): ask users to close other tabs, for example from a store:storage-blocked listener.

Encryption ​

beta.12 derived the key from the passphrase with a random salt in every process, so encrypted data was never readable on another device. With beta.13 every user has one keyring, wrapped by their passphrase and stored (wrapped only) by the server. beta.12 ciphertext on the server cannot be decrypted by anyone and is quarantined (LEGACY_ENCRYPTED_PAYLOAD). The device that wrote such records still holds them in plaintext locally: rewrite them from that device (for example, copy each into a new record) to share them. Details: Migrating from 1.0.0-beta.12 Encryption.

3. What to monitor ​

On the server (structured log events of the logger option):

  • The first start: the one-time migrations and the log-integrity scan (store.getLogIntegrityReport() lists quarantined rows).
  • session.protocol_deprecated: a beta.12 client is still connected. The server also counts these sessions: legacyProtocolSessions and lastLegacyProtocolSessionAt in /__kora/status (and kora status), kora_legacy_protocol_sessions_total on /__kora/metrics. The count is in memory per process and restarts at zero; the log event is the durable record across restarts. sum(increase(kora_legacy_protocol_sessions_total[14d])) == 0 means no instance accepted a protocol-1 session in two weeks (devices refused before the handshake are not counted). See Watching for protocol-1 clients. session.unverified_legacy_operation: a beta.12 operation whose id could not be verified was stored for its own node only.
  • session.legacy_anonymous_claim: a pre-claims node was re-issued to an anonymous device (see allowLegacyAnonymousClaims); node_claim.released follows each releaseNodeClaim. From beta.14, node_claim.handover: a signed-in device took over its own ownerless node.
  • session.forged_duplicate: an upload reused a stored id with different content (tampering or a broken client).
  • session.revalidation_failed, session.delivery_stalled, connection.rejected (max_connections): grant checks, slow consumers and capacity.

The server answers a refused operation to its author and does not log it; devices report each refusal (below), so forward sync:operation-rejected to your telemetry. A burst of SCOPE_REQUIRED session refusals after the upgrade means a scope binding is missing from scopeValues / resolveScopes.

On devices (forward these events to your telemetry):

EventMeaning
store:durability-lost, sync:durability-degradedNo durable local storage, or persisting keeps failing.
store:storage-blocked, store:storage-migrated, store:rematerializedThe one-time client migration (a beta.12 tab is still open; pools moved; records re-folded).
sync:operation-rejectedThe server refused a write (code, retriable); it is undone on its author.
sync:apply-failed, sync:apply-blocked, sync:apply-recoveredQuarantined or stalled inbound operations, and their recovery.
sync:node-id-rotated, sync:local-nodeThe device moved to a fresh node (claims, clones, held writes).
encryption:status, sync:suspended (encryption-locked)The keyring needs the passphrase.
sync:protocol-deprecatedThis client talks to an older server: upgrade the server.

useSyncStatus() (or app.sync.getStatus()) also reports heldOperations, localDurability and blockedFailure. See Error Codes for every code.

From beta.13 to beta.14 ​

beta.14 has no protocol or storage-format change: upgrade the packages together, servers first as usual. A beta.12 database can be upgraded straight to beta.14 with this guide; the @korajs/auth bind script is not needed (automatic handover, above). What behaves differently from beta.13, and what to do about it:

ChangeWhat to do
defineSchema refuses a unique or capacity constraint whose where holds an operator object, an array or an unknown field (SchemaValidationError). Such a constraint never matched anything in beta.13.Use equality values only, or enforce the rule in a server route.
A unique constraint's where also selects the records a write is compared against: "unique slug among status: 'published'" no longer lets a draft collide with a published record. A write that moves a record into the group and creates a duplicate is undone (the status change), not the record deleted.Nothing, unless you relied on drafts colliding.
createProductionServer with NODE_ENV=production answers 403 OPERATIONAL_ENDPOINT_DISABLED on an operational group whose token is unset (/__kora dashboard and status, metrics, backup export and import).Set KORA_ADMIN_TOKEN, KORA_METRICS_TOKEN and KORA_BACKUP_TOKEN (or operationalAuth), or pass operationalAuth.allowPublic: true on purpose.
A signed-in server whose resolved grant shares every user's data warns again at the first handshake.Return per-user scopes, or set unscopedSharing: 'allow' for one shared data set ('refuse' refuses such handshakes).
NODE_ID_CLAIMED carries nodeOwnership ('other-principal' or 'unowned'). While a browser's device node belongs to another user than the one signed in (a shared browser), local writes throw NodeOwnedByAnotherUserError (NODE_OWNED_BY_ANOTHER_USER) instead of being stored as the owner's.Handle the error in shared-device flows (sign the other user out, or use a separate browser profile).
importBackup converts a version-1 backup itself (convertedFromVersion: 1) instead of returning BACKUP_FORMAT_OUTDATED.Remove your convertBackupV1 fallback if you like; it still works.
Presence is relayed per record: a state whose cursor names a record reaches every session that can read that record (see Presence). A state without a cursor still stays within the identical download scope.Set cursor on states that should reach collaborators with different grants.
The template defaults are ./.kora/kora-server.db and ./.kora/kora-auth.db everywhere (the SQLite stores create the directory). Existing apps keep the paths in their own server.ts and .env.Nothing for existing apps.

New, opt-in: refreshScopes(userId) after a membership change, maxScopePredicateValues for large $in grants, spaFallback: 'extensionless' for service-worker route warming, findJsonStringValues(store) after an upgrade from a server older than beta.13, and retrySave() / hasUnsavedChanges on the rich-text controller.

Planned for a later release ​

Protocol 1 (beta.12 clients) is refused, experimental.legacyMerge and the deprecated MergeEngine / addWinsSet exports are removed, and allowLegacyAnonymousClaims defaults to false. beta.13 announced these for the next release; beta.14 keeps them because each would break part of the beta.12 upgrade path it completes (a device on a cached beta.12 bundle speaks protocol 1). The release that makes them will say so in its notes. Upgrade every client now.