Async Approval And Persistence
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, prepareMove: async ({ cards, target, signal }) => { const response = await fetch('/api/kanban/approve-move', { method: 'POST', signal, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cardIds: cards.map(card => card.id), target }), }); if (!response.ok) return 'Move approval was denied.'; return true; }, 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,};prepareMove 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.
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 prepareMove 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 prepareMove, 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.