Managed Saving and Conflicts
Use this advanced adapter when you want Kanban to coordinate save requests, versions, conflict choices and undo/redo acceptance. For the ordinary paged-server path, start with application-owned saving.
Important: managed persistence requires the complete card collection. It cannot run together with kanban.remote. Read caching does not make a partial dataset complete, and optimistic mutation settings do not belong to the read-cache configuration.
Approve and confirm a local mutation
Advanced: a complete local dataset uses an optional managed persistence callback. No remote loader is configured.
Try it: Approve a move, then reject the next move. Watch the accepted board state.
What to notice: The callback returns a receipt for every changed card; a rejected mutation is rolled back.
TypeScript source
Install the packages from Getting Started, then use this source in your app.
import type { KanbanPersistenceOptions } from '@revolist/kanban';import type { WorkCard } from './first-board';
// Advanced local-data example. This is NOT the remote loader configuration.// In production, commit calls an authenticated, atomic, idempotent endpoint.export function createManagedPersistence() { let rejectNext = false; const revisions = new Map<string, number>(); const persistence: KanbanPersistenceOptions<WorkCard> = { getVersion: (_card, cardId) => revisions.get(String(cardId)) ?? 1, approveMove: async ({ signal }) => { signal.throwIfAborted(); return true; }, async commit(request) { request.signal.throwIfAborted(); if (rejectNext) { rejectNext = false; return { operationId: request.operationId, status: 'rejected', message: 'The simulated manager rejected this move.' }; } return { operationId: request.operationId, status: 'accepted', versions: request.changes.map(change => { const version = (revisions.get(String(change.cardId)) ?? 1) + 1; revisions.set(String(change.cardId), version); return { cardId: change.cardId, version }; }) }; }, }; return { persistence, rejectNext() { rejectNext = true; } };}// const managed = createManagedPersistence();// grid.kanban = { ...grid.kanban, persistence: managed.persistence };// await kanban.moveCardsAsync(['task-1'], { columnProp: 'doing' });import { defineCustomElements } from '@revolist/revogrid/loader';import { KanbanPlugin, type KanbanConfig } from '@revolist/kanban';import '@revolist/revogrid-pro/styles.css';import '@revolist/kanban/styles.css';
export interface WorkCard { id: string; title: string; status: string; order: number; description?: string; team?: string; owner?: string; priority?: string; tags?: string[]; progress?: number; startDate?: string; endDate?: string;}export const demoCards: WorkCard[] = [ { id: 'task-1', title: 'Research customer needs', description: 'Collect feedback for the next release.', status: 'todo', team: 'product', owner: 'Ana', priority: 'normal', order: 1000 }, { id: 'task-2', title: 'Build onboarding', description: 'Make the first session useful.', status: 'doing', team: 'product', owner: 'Mia', priority: 'high', order: 1000 }, { id: 'task-3', title: 'Improve API errors', description: 'Return actionable validation messages.', status: 'todo', team: 'platform', owner: 'Rui', priority: 'normal', order: 2000 }, { id: 'task-4', title: 'Release checklist', description: 'Ready for the next deployment.', status: 'done', team: 'platform', owner: 'Sam', priority: 'normal', order: 1000 },];export const boardConfig: KanbanConfig<WorkCard> = { columns: [{ prop: 'todo', name: 'To do', size: 210 }, { prop: 'doing', name: 'In progress', size: 210 }, { prop: 'done', name: 'Done', size: 210 }], card: { titleField: 'title' }, cardRowHeight: 88, addCard: false, swimlaneColumn: false,};
export async function configureBoard( grid: HTMLRevoGridElement, config: KanbanConfig<WorkCard> = boardConfig, cards: readonly WorkCard[] = demoCards, extraPlugins: HTMLRevoGridElement['plugins'] = [],) { await defineCustomElements(); grid.plugins = [KanbanPlugin, ...extraPlugins]; // The packaged editor and history install automatically. grid.columns = [{ prop: 'title', name: 'Title' }, { prop: 'status', name: 'Status' }, { prop: 'owner', name: 'Owner' }]; // Cached remote data can hydrate immediately: finish initial grid setup first. grid.kanban = config.remote ? { ...config, remote: false } : config; grid.source = cards.map(card => ({ ...card })); await grid.componentOnReady(); const plugins = await grid.getPlugins(); const kanban = plugins.find((plugin): plugin is KanbanPlugin<WorkCard> => plugin instanceof KanbanPlugin); if (!kanban) throw new Error('Kanban could not start.'); if (config.remote) grid.kanban = config; return kanban;}
// Give your container a width and mount once after it exists in the DOM.export async function createKanbanBoard(container: HTMLElement) { await defineCustomElements(); const grid = document.createElement('revo-grid'); grid.style.cssText = 'display:block;width:100%;height:400px'; container.append(grid); try { const kanban = await configureBoard(grid); return { grid, kanban, destroy() { grid.remove(); } }; } catch (error) { grid.remove(); throw error; }}Try it: make a change and inspect the save state. This complete-data simulation demonstrates managed acceptance and recovery; it is separate from the paged HTTP examples.
Choose app-owned or managed saving
Section titled “Choose app-owned or managed saving”Kanban does not require commit. The remote object only loads pages. Listen to committed events if your app already owns saving, pending UI, failures and reconciliation; see the live server-side example.
Use the optional persistence adapter below when Kanban should coordinate versioned writes, conflicts, rollback and server-approved history for a complete dataset. This is a separate integration from paged remote reads.
App-owned move events
Section titled “App-owned move events”For a board without the managed adapter, use application-owned saving. Do not attach another save listener to the managed example below.
What the hooks mean
Section titled “What the hooks mean”| Hook | Purpose | When needed |
|---|---|---|
commit(request) | Submit an atomic batch and return an accepted/rejected/conflict receipt. | Required only when opting into managed persistence. |
getVersion(card, cardId) | Read the initial backend revision for a card. Kanban sends it as expectedVersion and advances its own version map from accepted receipts. | When the server uses optimistic concurrency. null means no known revision. Use the opaque revision supplied by your backend rather than generating a local counter. |
approveMove(request) | Approve a proposed move asynchronously, before changing its location. Return true to continue, false or a message to stop. It does not save the move. | Optional server permission checks or approval workflows. Omit it for ordinary moves. |
approveMove is the explicit approval hook. The older prepareMove name remains a deprecated alias; when both are configured, approveMove wins. It applies to moves rather than every edit, and the final commit must still validate authoritatively. Local synchronous vetoes use beforekanbancardmove or rules. Use moveCardsAsync() for programmatic moves when approval is configured.
For example, a card loaded with revision "r7" is submitted with expectedVersion: "r7". If another user already saved "r8", the server returns a conflict instead of overwriting their work. An accepted receipt supplies the next revision; your app does not need to mutate the card’s revision field after each save.
Optional async move approval
Section titled “Optional async move approval”Add this hook only when your application needs an approval step. The save callback still runs separately after approval and local rule validation.
const approveMove: NonNullable<KanbanPersistenceOptions<Card>['approveMove']> = async ({ cardIds, target, signal }) => { const response = await fetch('/api/approve-move', { method: 'POST', signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cardIds, target }), }); return response.ok ? true : 'This move was not approved.';};Include this approveMove property when creating your adapter object. Card and KanbanPersistenceOptions are the application card type and public type used below.
Configure one save adapter
Section titled “Configure one save adapter”Configure persistence to save canonical card changes through an asynchronous adapter.
Load the complete canonical dataset and its server versions before enabling persistence.
Partial/indexed remote loading cannot be combined with this adapter.
import { KanbanPlugin, type KanbanPersistenceOptions } from '@revolist/kanban';
type Card = { id: string; title: string; status: string; order: number };const loaded: { cardId: string; card: Card; version: string | number }[] = await fetch('/api/kanban').then(response => response.json());const initialVersions = new Map(loaded.map(row => [row.cardId, row.version]));const persistence: KanbanPersistenceOptions<Card> = { optimistic: true, getVersion: (_card, id) => initialVersions.get(String(id)) ?? null, commit: async ({ signal, ...request }) => { const response = await fetch('/api/kanban', { method: 'POST', signal, headers: { 'content-type': 'application/json' }, body: JSON.stringify(request), }); // A network error, timeout or non-2xx response may happen AFTER a server write. // Throwing retains the operation for an idempotent retry; it does not assume rollback. if (!response.ok) throw new Error(`Save returned HTTP ${response.status}`); return response.json(); },};
grid.plugins = [KanbanPlugin];grid.source = loaded.map(row => row.card);grid.kanban = { columns: [{ prop: 'todo', name: 'To do' }, { prop: 'doing', name: 'Doing' }], persistence,};approveMove is optional. The runnable reference API described below implements
GET/POST /cards; omit that approval hook when running the reference unchanged.
A real approval endpoint can refresh permissions, but the final write endpoint
must enforce authorization, WIP and transition rules again atomically.
The existing editor and create/update/delete methods capture one canonical save
automatically. Do not also save from kanbancardmove, editor callbacks or gridedit:
that would duplicate writes. A batch contains all changed canonical cards, including
rank-rebalanced neighbors; projected grid rows are never persisted.
Shared command engine and read invalidation
Section titled “Shared command engine and read invalidation”The persistence.commit, getVersion and optimistic settings keep their signatures. approveMove names the approval step explicitly; prepareMove remains compatible. Each commit attempt uses the shared command engine internally;
Kanban still owns per-card expected versions, queued field deltas, conflict choices,
explicit idempotent retry and accepted-only history. No automatic write retry is
added. See Kanban command integration and types.
Optional persistence.source accepts a shared query scope and invalidates its reads
after local acceptance and history recording. Use it to refresh other views of the
same authorized dataset. It does not enable paged reads and persistence in the same
board: the complete-source restriction remains. If read invalidation fails, the
write remains accepted; it is never resubmitted or recorded in history again.
// source is a DataQueryScope created by createDataQueryContext from Pro.const connectedPersistence: KanbanPersistenceOptions<Card> = { ...persistence, source,};Await a move
Section titled “Await a move”const plugins = await grid.getPlugins();const kanban = plugins.find(plugin => plugin instanceof KanbanPlugin)!;const committed = await kanban.moveCardsAsync(['a'], { columnProp: 'doing' });The promise resolves after remote acceptance or a definitive rejection/cancellation.
While a network outcome or version conflict is unresolved it remains pending until
the user reconciles it. Pointer, keyboard and context-menu moves use this path
automatically when persistence is configured. Rules and ranks are recomputed after
approval against current cards. With approveMove configured, synchronous direct
moveCards returns false; use moveCardsAsync to await approval. Without the
adapter, moveCards retains its boolean contract and fast path.
Atomic server receipts
Section titled “Atomic server receipts”Every request has an operationId, an action, and changes. Each change includes
cardId, detached previousCard/card snapshots, sourceIndex, and expectedVersion.
A null card deletes a card; a null expected version means the identity is absent.
Versions are opaque strings or numbers, with null reserved for absence.
Return exactly one of these correlated results:
// The batch was durably committed. Include a version for every changed identity.{ operationId, status: 'accepted', versions: [{ cardId: 'a', version: 42 }] }
// Definitive policy/validation rejection: no part of the batch was written.{ operationId, status: 'rejected', message: 'Doing is at capacity.' }
// Compare-and-swap failed: no part was written. Include ALL affected identities.{ operationId, status: 'conflict', message: 'Card changed elsewhere.', cards: [{ cardId: 'a', card: latestCard, version: 43 }] }The reference serves all three result envelopes with HTTP 200. If your endpoint
uses HTTP 409 for a conflict, parse and return its correlated conflict envelope
in commit instead of throwing for that known response. Unstructured HTTP failures
still have an unknown outcome.
Cache the receipt by operation ID in the same database transaction as the writes. The same ID/body must return the original receipt even after a response is lost. Use a new ID only for a new logical action; the client does this when rebasing a known conflict. Never report a timeout as a definitive rejection.
Pending, errors and conflict choices
Section titled “Pending, errors and conflict choices”The board exposes non-blocking status, Retry save, Cancel (before submission),
Use server data, Retry changes, and Dismiss controls. Hosts can also
listen to kanbanpersistencechange and query getPersistenceState(). The shared
status panel and badges follow Pro theme
tokens and keep actions usable in narrow containers.
kanban.retryPersistence(operationId); // Same ID and body after unknown failurekanban.cancelPersistence(operationId); // Cancels unsent work; submitted work becomes unknownkanban.resolvePersistenceConflict(operationId, 'server'); // Load authoritative fields, retain later local editskanban.resolvePersistenceConflict(operationId, 'retry'); // Rebase this delta on the latest version with a new IDWrites touching the same identity serialize; unrelated identities can save concurrently.
Queued payloads are rebuilt from acknowledged state plus their own field changes.
A rejected move therefore cannot reappear inside a later title-save payload.
Rollback reprojects only the affected fields and retains later edits and unrelated cards.
A conflict pauses overlapping work until an explicit choice. An error after submission
keeps optimistic state and ordering until the same operation is retried.
Set optimistic: false to leave proposed changes off the board until accepted.
History and lifecycle
Section titled “History and lifecycle”Only accepted writes enter undo history. Undo/redo waits for a server-approved write before shared history changes its stacks; a rejected replay leaves both cards and history intact. Unresolved ordinary writes/approvals block replay. Hosts with their own history controls should also disable those controls while persistence is busy.
Replacing grid.source, replacing the adapter, disabling Kanban or destroying the
board invalidates old callbacks. A response cannot overwrite the replacement dataset.
Aborting a submitted request does not prove that the server cancelled it: reload
authoritative data before starting a new board/session after abandonment.
Keep card identities stable. For server-assigned IDs, use the packaged editor’s
existing asynchronous createCardId callback to allocate the ID before creation.
Response-time identity remapping is not supported. Extra server-normalized card fields
must arrive through a subsequent authoritative reload; receipts advance revisions
and do not overwrite cards with arbitrary whole-object server snapshots.
Runnable reference
Section titled “Runnable reference”The source checkout contains packages/kanban/server-reference/server.ts:
pnpm exec tsx packages/kanban/server-reference/server.tsIt binds to 127.0.0.1:4042, serves GET/POST /cards, applies atomic version checks,
caches operation receipts, and enforces a sample Doing capacity of two. Proxy your
frontend’s /api/kanban route to /cards. Its process-local storage is a reference;
production storage, durable idempotency, authentication and tenant isolation belong
to the host. The adjacent README describes that boundary and server-ID allocation.
Collapse activity
Section titled “Collapse activity”The activity popup floats above the board using the browser’s Popover API and a subtle shadow. Use Collapse activity to reduce it to a compact bar with the number of pending messages. Use Expand activity to restore the details and actions. Escape also collapses it. Requests continue while collapsed, and new activity updates the count without expanding the popup.
Translate activity feedback
Section titled “Translate activity feedback”Use kanban.labels to translate the activity popup, just like other Kanban
controls. kanban.locale formats its numeric count. Translate both visible
labels and accessible control/count labels:
grid.kanban = { ...grid.kanban, locale: 'pt-PT', labels: { ...grid.kanban?.labels, persistenceTitle: 'Atividade', persistencePreparing: 'A aguardar aprovação', persistenceQueued: 'Gravação em espera', persistenceSaving: 'A guardar alterações', persistenceError: 'Não foi possível confirmar a gravação', persistenceConflict: 'Rever alterações', persistenceCommitted: 'Alterações guardadas', persistenceRejected: 'Alteração recusada', persistenceCancelled: 'Pedido cancelado', persistencePreparingDescription: 'A aguardar aprovação do movimento.', persistenceQueuedDescription: 'A aguardar a gravação anterior.', persistenceSavingDescription: 'As suas alterações estão a ser guardadas.', persistenceConflictDescription: 'Este cartão foi atualizado por outra pessoa.', persistenceNewerEdits: 'As suas edições mais recentes são preservadas.', persistenceRetryChanges: 'Tentar novamente', persistenceUseServer: 'Usar dados remotos', persistenceRetrySave: 'Repetir gravação', persistenceCancel: 'Cancelar', persistenceDismiss: 'Dispensar', persistenceCollapse: 'Recolher atividade', persistenceExpand: 'Expandir atividade', persistenceActivityCount: count => `${count} alterações`, },};The persistence* entries in KanbanLabels also cover internally generated
notices, including denied approval, cancellation, changed card existence,
invalid receipts, and retry instructions. persistenceInitialReadError(message)
and persistenceRetryMessage(message) format diagnostic details.
Messages returned by your persistence adapter remain application-owned. Return
localized messages from commit and approveMove, or provide
labels.persistenceMessage(state) to map them to translated UI text. Return
undefined to use the translated status description instead. Label changes
apply to the mounted board without replacing the persistence adapter.
The popup animates its entrance, exit, and compact/expanded transitions. These short transitions are skipped when the user requests reduced motion. Closing activity never delays or changes the outcome of a save.
Command receipts
Section titled “Command receipts”Kanban’s existing KanbanPersistenceOptions<Card> uses this command engine for
individual commit attempts. Keep commit(request) unchanged: it receives the same
operation ID, action, detached card changes, per-card expectedVersion values and
attempt-specific signal. Return the existing KanbanPersistenceResult<Card>
accepted/rejected/conflict receipt; no generic envelope is required in application
code. See approval, persistence and history.
| Configuration type / field | Behavior |
|---|---|
KanbanPersistenceOptions<Card>.commit | Existing atomic/idempotent endpoint; no automatic retry. Thrown or invalid responses remain unknown. |
approveMove, getVersion, optimistic | Existing approval, initial opaque per-card revisions and optimistic/pessimistic projection behavior. |
source?: { accepted(): Promise<void> } | Optional shared scope to invalidate after canonical acceptance and history; source errors cannot turn acceptance into rejection or resubmit the write. |
KanbanPersistenceRequest<Card> / KanbanPersistenceResult<Card> | Existing public request and receipt types, exported from @revolist/kanban. |
Kanban keeps its identity queue and acknowledged bases. Overlapping card changes serialize; independent cards can save concurrently. A retry uses the same immutable logical payload and operation ID, including when a previous abort-ignoring attempt is still physically outstanding. A conflict rebase is a new logical operation with fresh expected versions. Cancelling after submission retains an unknown outcome; late old-attempt replies cannot acknowledge history. Only accepted receipts advance per-card versions and record history.
Use a backend with the existing idempotency/compare-and-swap guarantee. Configure
persistence.source only when its readers represent the same authorized dataset.
The board still rejects remote plus built-in persistence: full-source integration
is a separate feature, and Query does not make partial board state complete.
Next steps
Section titled “Next steps”See server-side loading, undo and redo and events/API. See the Kanban API for exact types and signatures.