From 71fde0f3c0ccb8393c0206231c3cf0d403ce7381 Mon Sep 17 00:00:00 2001 From: bach Date: Sun, 6 Sep 2026 12:49:56 +0200 Subject: [PATCH] Add live polling and optimistic locking for concurrent inline edits Two-part fix so multiple people can edit /lignes at once without clobbering each other, and see each other's changes without a manual reload -- no Socket.io/websocket infra, just what fits the existing fetch-based architecture: 1. Polling (POLL_INTERVAL_MS = 8s): pollForChanges() asks JSON:API for any ligne_comptable changed since the last check (filtering on the `changed` field -- confirmed live that JSON:API only accepts a raw Unix timestamp for this, not the ISO string it returns in responses, silently matching everything otherwise) and mergeChangedRows() patches matching rows in place via Object.assign (not a `rows` reassignment, so it doesn't re-trigger the reconciliation-resolution watcher for routine polls). A row currently being edited is skipped entirely rather than overwritten out from under an in-progress keystroke. 2. Optimistic locking: every row now carries its `changed` timestamp, sent back on every inline edit (updateType/updateField). A new checkConflict() compares it against the node's actual changed time before saving and rejects with 409 if they differ -- someone else saved this exact line in between. On a 409, refreshSingleRow() re-fetches just that node and patches it in place so the view self-corrects instead of staying stuck on the stale state that caused the rejection. Verified live end-to-end against an isolated temporary test node (not real data): an external edit correctly appeared in the browser within one poll cycle with no reload; a save using a stale `changed` value was rejected with the conflict error, confirmed via direct DB query that it left the node's data completely untouched, and the view auto-corrected to show the other edit. Test node and its paragraph fully cleaned up afterward. --- .../custom/figli_compta_ledger/js/home.js | 146 ++++++++++++++++-- .../Controller/LedgerActionsController.php | 39 +++++ 2 files changed, 174 insertions(+), 11 deletions(-) diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 328caab..a51f678 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -57,6 +57,12 @@ // more than enough rounds to walk the entire MIN_LOADABLE_DATE.. // MAX_LOADABLE_DATE span at EXTEND_MONTHS per round. const MAX_ENSURE_SCROLLABLE_ROUNDS = 30; + // How often to poll for other users' changes (see pollForChanges()) -- + // a plain setInterval rather than anything push-based (no Socket.io/ + // websocket infra for a 6-person internal tool); this is far more + // frequent than table content actually changes, but the requests are + // cheap and it keeps everyone's view close to live. + const POLL_INTERVAL_MS = 8000; // Absolute bounds on how far the window can extend. Without these, // loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward @@ -148,6 +154,23 @@ return fetchLignes(API_BASE + '?' + params.toString()); } + // Any ligne_comptable changed after `tsSeconds` (a Unix timestamp) -- + // backs the polling in pollForChanges() below, so one user's edit + // shows up in everyone else's table without a manual reload. `changed` + // is stored as a Unix timestamp internally; JSON:API exposes it as an + // ISO 8601 string in *responses* but only accepts the raw timestamp as + // a *filter value* -- confirmed live (an ISO string filter value + // silently matched everything instead of narrowing the query). + async function fetchChangedSince(tsSeconds) { + const params = new URLSearchParams(); + params.set('include', 'field_repartition,field_repartition.field_compte,field_client,field_entree_liee'); + params.set('filter[changedFilter][condition][path]', 'changed'); + params.set('filter[changedFilter][condition][operator]', '>'); + params.set('filter[changedFilter][condition][value]', String(tsSeconds)); + params.set('sort', 'changed'); + return fetchLignes(API_BASE + '?' + params.toString()); + } + async function fetchClientNames() { // page[limit]=200 is silently clamped to core's hard cap of 50 by // JSON:API (Query\OffsetPage::SIZE_MAX) -- with 106 client terms, @@ -186,32 +209,45 @@ // full edit form. Fetches a fresh CSRF token each time (core's // /session/token) rather than caching one -- this is an occasional // action, not worth the complexity of handling a stale cached token. - async function updateLigneType(nid, type) { + // `changed` (the row's last-known changed timestamp) lets the server + // reject a save-over-a-stale-view instead of silently overwriting + // someone else's concurrent edit -- see LedgerActionsController:: + // checkConflict(). The thrown error's `.status` lets callers tell a + // 409 conflict apart from any other failure. + async function updateLigneType(nid, type, changed) { const tokenRes = await fetch('/session/token'); const token = await tokenRes.text(); const res = await fetch('/lignes/' + nid + '/type', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, - body: JSON.stringify({ type }), + body: JSON.stringify({ type, changed }), }); const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status)); + if (!res.ok) { + const err = new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status)); + err.status = res.status; + throw err; + } return json; } // POST /lignes/{nid}/champ -- change client/facture/libellé without - // opening the full edit form. Same fresh-token-per-call reasoning as - // updateLigneType() above. - async function updateLigneField(nid, field, value) { + // opening the full edit form. Same fresh-token-per-call and conflict- + // detection reasoning as updateLigneType() above. + async function updateLigneField(nid, field, value, changed) { const tokenRes = await fetch('/session/token'); const token = await tokenRes.text(); const res = await fetch('/lignes/' + nid + '/champ', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, - body: JSON.stringify({ field, value }), + body: JSON.stringify({ field, value, changed }), }); const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status)); + if (!res.ok) { + const err = new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status)); + err.status = res.status; + throw err; + } return json; } @@ -291,6 +327,13 @@ rows.push({ id: node.id, nid: attrs.drupal_internal__nid, + // ISO 8601 string (ok as an opaque token here, only ever + // compared for equality/passed straight back to the server) -- + // sent back on the next inline edit so the server can detect + // "someone else saved this line in between" and reject instead + // of silently overwriting. See LedgerActionsController:: + // checkConflict() and pollForChanges()/mergeChangedRows() below. + changed: attrs.changed, date: attrs.field_date_ligne, type: attrs.field_type_ligne, client: clientTerm ? clientTerm.attributes.name : null, @@ -352,6 +395,9 @@ // group again for every other sortie that happens to link to // the same entrée. resolvedLinkGroups: new Set(), + // Unix timestamp (seconds) -- everything with `changed` after + // this has appeared since the last poll. See pollForChanges(). + lastPollTs: null, // Which row's type badge is currently showing its inline