demo D237
Editing a remote source, safely
Optimistic edit, reconcile to server truth, and a visible revert when the write is refused
createPushdownSource({ adapter, edit: true })
The configuration
'writeback-adapter': () => {
const store = new Map([
['1', { id: '1', part: 'Bushing A', line: 'Line 1', bore: 10.0, qty: 120 }],
['2', { id: '2', part: 'Bushing B', line: 'Line 1', bore: 12.5, qty: 80 }],
['3', { id: '3', part: 'Collar C', line: 'Line 2', bore: 8.25, qty: 200 }],
['4', { id: '4', part: 'Sleeve D', line: 'Line 2', bore: 15.75, qty: 45 }],
['5', { id: '5', part: 'Flange E', line: 'Line 3', bore: 20.1, qty: 30 }],
]);
return {
rows: [], config: {},
mount: (el: HTMLElement, LG: any) => {
el.textContent = '';
el.style.cssText = 'display:flex;flex-direction:column;gap:10px;height:640px';
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;flex-wrap:wrap;gap:14px;align-items:center;font:13px system-ui;color:var(--ink-2)';
bar.innerHTML =
'<label style="display:inline-flex;gap:6px;align-items:center"><input type="checkbox" id="wb-normalise" checked> server normalises the value (reconcile to truth)</label>' +
'<label style="display:inline-flex;gap:6px;align-items:center"><input type="checkbox" id="wb-reject"> reject the next write</label>' +
'<label style="display:inline-flex;gap:6px;align-items:center"><input type="checkbox" id="wb-conflict"> row moved underneath (surface conflict)</label>';
const gridEl = document.createElement('div');
gridEl.style.cssText = 'flex:1;min-height:0';
const status = document.createElement('div');
status.style.cssText = 'font:12.5px system-ui;color:var(--ink-3)';
status.textContent = 'Double-click Bore or Qty and change it.';
const log = document.createElement('div');
log.style.cssText = 'height:170px;overflow-y:auto;background:#0f1420;color:#dfe3ea;border-radius:8px;padding:10px 12px;font:12px ui-monospace,monospace;line-height:1.6';
el.append(bar, gridEl, status, log);
const norm = bar.querySelector<HTMLInputElement>('#wb-normalise')!;
const reject = bar.querySelector<HTMLInputElement>('#wb-reject')!;
const conflict = bar.querySelector<HTMLInputElement>('#wb-conflict')!;
const line = (cls: string, msg: string) => {
const div = document.createElement('div');
div.innerHTML = `<span style="color:#7fd1ff">${new Date().toLocaleTimeString()}</span> <span style="color:${cls}">${msg}</span>`;
log.prepend(div);
};
const adapter = {
name: 'mock-remote',
capabilities: { filter: 'tree', sort: true, mutate: { update: true, returning: 'row' } },
async execute() {
const rows = [...store.values()].map((r) => ({ ...r }));
return { rows, total: rows.length };
},
async mutate(op: any) {
if (op.kind !== 'update') throw new Error(`unsupported mutation: ${op.kind}`);
await new Promise((r) => setTimeout(r, 250));
if (reject.checked) {
reject.checked = false;
return { ok: false, reason: 'the server rejected the write (validation failed)' };
}
const row = store.get(op.key);
if (!row) throw new Error(`no such row: ${op.key}`);
Object.assign(row, op.patch);
if (norm.checked && 'bore' in op.patch) {
row.bore = Math.round(Number(op.patch.bore) * 10) / 10;
}
const result: any = { ok: true, rows: [{ ...row }] };
if (conflict.checked) {
conflict.checked = false;
result.conflict = { serverRow: { ...row } };
}
return result;
},
};
const source = LG.createPushdownSource({ adapter, compute: LG, edit: true });
const grid = LG.createGrid(gridEl, {
rowKey: 'id', theme: 'light', edit: source.edit,
toolPanel: { side: 'left', panels: ['columns'] },
columns: [
{ field: 'part', title: 'Part', layout: { width: 140, pin: 'start' }, edit: { enabled: true, editor: 'text' } },
{ field: 'line', title: 'Line', layout: { width: 100 } },
{ field: 'bore', title: 'Bore', type: 'millimetres', format: { decimals: 2 }, layout: { width: 120 }, edit: { enabled: true, editor: 'number' } },
{ field: 'qty', title: 'Qty', type: 'number', layout: { width: 100 }, edit: { enabled: true, editor: 'number' } },
],
source,
});
const offPending = grid.on('cell:pending', (e: any) =>
line('#ffd43b', `pending — ${e.colId} on row ${e.key} optimistically set to <b>${e.value}</b> (was ${e.before})`));
const offConfirmed = grid.on('cell:confirmed', (e: any) =>
line('#8ce99a', `confirmed — ${e.colId} on row ${e.key} is <b>${e.value}</b>${e.superseded ? ' (superseded)' : ''}`));
const offReverted = grid.on('cell:reverted', (e: any) =>
line('#ff8787', `reverted — ${e.colId} on row ${e.key} rolled back to <b>${e.restored}</b>. Reason: ${e.reason}`));
const offConflict = grid.on('cell:conflict', (e: any) =>
line('#ffd43b', `conflict — the server row for ${e.key} had moved; last-write-wins kept <b>${e.value}</b>, server truth surfaced`));
return () => {
offPending?.(); offConfirmed?.(); offReverted?.(); offConflict?.();
grid?.destroy?.();
};
},
foot: ['sources are read-only by default; this one opts in with mutate: { update: true }', 'double-click Bore or Qty: pending, then confirmed to server truth', 'tick "reject" or "conflict" before an edit to see the other two outcomes'],
};
}
Editing a remote source without waiting for the round trip, safely
A pushdown source is read-only by default: an adapter says nothing about writing, so the grid refuses to try. Editing one is an adapter opt-in, not a grid-level switch. An adapter declares capabilities.mutate: { update: true, returning: 'row' } and implements a mutate(op) method that persists one change, and createPushdownSource({ adapter, edit: true }) synthesises the edit.commit that wires a cell edit to it, the same bridge every pushdown adapter, including the DuckDB and OData ones this site’s other demos use, rides underneath. The sequence a host actually sees is: a cell edit applies to the grid immediately and fires cell:pending, so typing never waits on the network; the adapter’s mutate call resolves and, when it returns the authoritative row (returning: 'row'), the cell reconciles to whatever the server actually stored and fires cell:confirmed; a rejected write reverts the cell visibly to its prior value and fires cell:reverted with the reason the adapter gave, rather than leaving a value on screen that was never saved; and a write that discovers the row moved underneath it surfaces cell:conflict without dropping the edit, last-write-wins with the divergence named rather than swallowed. A newer edit to the same cell supersedes an older one still in flight, and the older write’s stale result is never allowed to land back over it.
How do I make a remote or pushdown data source editable?
Declare mutate: { update: true, returning: 'row' | 'none' } on the adapter’s capabilities, and implement a mutate(op) method that receives { kind: 'update', key, patch } and persists it. Pass edit: true to createPushdownSource({ adapter, edit: true }) and the grid gains a working edit.commit wired to that method automatically; a source whose adapter declares nothing about mutation stays read-only and a write to it is refused rather than silently dropped.
What happens if two people edit the same remote row at once?
The later edit wins and is the one sent to the server. If the adapter’s response indicates the row had already diverged, the grid fires cell:conflict alongside the normal confirmation, carrying the server’s row so the interface can show the divergence, rather than either silently overwriting it or blocking the edit that already applied.