v0.3.17-beta: detecteer save-conflicten tussen tabbladen
- Nieuwe migratie db/009: users.data_rev + data_updated_at en de
user_data_versions-tabel (voor de komende versiegeschiedenis)
- GET /me/data geeft nu {data, rev}; PUT /me/data controleert een optionele
x-data-rev-header en weigert een verouderde schrijver met 409 (zonder
header blijft het oude laatste-schrijver-wint-gedrag voor oude clients)
- core.js: rev wordt bijgehouden en meegestuurd; bij 409 toont de opslaan-
knop "Elders bewerkt" en haalt een klik eerst de nieuwste serverversie op
in plaats van te overschrijven
- beforeunload-waarschuwing bij een openstaande, lopende of mislukte save
- Nieuwe test test/save-system.test.js (rev mee, 409, legacy zonder header)
This commit is contained in:
parent
d0ab0b2483
commit
b28d25296d
5 changed files with 170 additions and 16 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.16-beta
|
||||
0.3.17-beta
|
||||
|
|
|
|||
16
db/009_save_system.sql
Normal file
16
db/009_save_system.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Opslaansysteem: optimistische concurrency + versiegeschiedenis.
|
||||
-- data_rev telt elke wijziging van users.data; een client stuurt de rev mee
|
||||
-- die hij geladen heeft en de update slaagt alleen als die nog actueel is
|
||||
-- (anders 409: een ander tabblad/digibord schreef eerder). user_data_versions
|
||||
-- bewaart periodieke snapshots van de vorige data zodat een leerkracht een
|
||||
-- eerdere versie kan terugzetten.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_rev BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_updated_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_data_versions (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_versions ON user_data_versions(user_id, created_at DESC);
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"use strict";
|
||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
||||
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
||||
const VERSION = "0.3.16-beta";
|
||||
const VERSION = "0.3.17-beta";
|
||||
(function(){
|
||||
const tag = document.getElementById("verTag");
|
||||
tag.textContent = "v"+VERSION;
|
||||
|
|
@ -189,7 +189,8 @@ const I18N = {
|
|||
errPass:"Wachtwoord: minimaal 4 tekens.",
|
||||
errCrypto:"Inloggen werkt niet in deze browser.",
|
||||
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen",
|
||||
saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen"
|
||||
saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen",
|
||||
saveConflict:"⚠ Elders bewerkt", saveConflictHint:"In een ander tabblad bewerkt. Klik om de nieuwste versie te laden (jouw niet-opgeslagen wijzigingen hier vervallen)."
|
||||
},
|
||||
en:{
|
||||
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
|
||||
|
|
@ -351,7 +352,8 @@ const I18N = {
|
|||
errPass:"Password: at least 4 characters.",
|
||||
errCrypto:"Login does not work in this browser.",
|
||||
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first",
|
||||
saving:"Saving…", saveFailed:"⚠ Not saved"
|
||||
saving:"Saving…", saveFailed:"⚠ Not saved",
|
||||
saveConflict:"⚠ Edited elsewhere", saveConflictHint:"Edited in another tab. Click to load the latest version (your unsaved changes here will be lost)."
|
||||
}
|
||||
};
|
||||
let LANG = "nl";
|
||||
|
|
@ -398,7 +400,7 @@ try{ localStorage.removeItem("teach.token"); }catch(e){}
|
|||
let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */
|
||||
|
||||
async function api(path, opts={}){
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const headers = { "Content-Type": "application/json", ...(opts.headers||{}) };
|
||||
const r = await fetch("/api" + path, {
|
||||
credentials: "same-origin",
|
||||
method: opts.method || (opts.body ? "POST" : "GET"),
|
||||
|
|
@ -406,7 +408,11 @@ async function api(path, opts={}){
|
|||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||
});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(j.error || ("fout " + r.status));
|
||||
if(!r.ok){
|
||||
const err = new Error(j.error || ("fout " + r.status));
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
|
|
@ -418,17 +424,22 @@ async function api(path, opts={}){
|
|||
==========================================================*/
|
||||
let saveTimer = null;
|
||||
let lastSaveError = null;
|
||||
/* rev van de data zoals geladen van de server; gaat als x-data-rev mee met
|
||||
elke save zodat de server een verouderde schrijver kan weigeren (409) */
|
||||
let DATA_REV = 0;
|
||||
let saveConflict = false;
|
||||
let saveInFlight = false;
|
||||
function canPersist(){
|
||||
return !!currentUser && currentUser.role !== "pupil";
|
||||
}
|
||||
function persistUser(){
|
||||
if(!canPersist()) return Promise.resolve(true);
|
||||
return api("/me/data", { method:"PUT", body:{
|
||||
return api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{
|
||||
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS
|
||||
}}).then(()=>{ lastSaveError = null; return true; })
|
||||
.catch(e=>{ lastSaveError = e.message; return false; });
|
||||
}}).then(res=>{ lastSaveError = null; saveConflict = false; if(typeof res.rev === "number") DATA_REV = res.rev; return true; })
|
||||
.catch(e=>{ lastSaveError = e.message; saveConflict = e.status === 409; return false; });
|
||||
}
|
||||
/* status op de opslaan-knop: idle / saving / saved / error */
|
||||
/* status op de opslaan-knop: idle / saving / saved / error / conflict */
|
||||
function setSaveStatus(state){
|
||||
const btn = document.getElementById("btnSave");
|
||||
const lbl = btn.querySelector("span");
|
||||
|
|
@ -436,6 +447,11 @@ function setSaveStatus(state){
|
|||
btn.title = "";
|
||||
if(state==="saving"){ lbl.textContent = T("saving"); }
|
||||
else if(state==="saved"){ lbl.textContent = T("boardSaved"); btn.classList.add("ok"); }
|
||||
else if(state==="conflict"){
|
||||
lbl.textContent = T("saveConflict");
|
||||
btn.classList.add("err");
|
||||
btn.title = T("saveConflictHint");
|
||||
}
|
||||
else if(state==="error"){
|
||||
lbl.textContent = T("saveFailed");
|
||||
btn.classList.add("err");
|
||||
|
|
@ -446,15 +462,25 @@ function setSaveStatus(state){
|
|||
let saveStatusTimer = null;
|
||||
async function doSave(){
|
||||
if(!canPersist()) return false;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
stashCurrent();
|
||||
setSaveStatus("saving");
|
||||
saveInFlight = true;
|
||||
const ok = await persistUser();
|
||||
saveInFlight = false;
|
||||
clearTimeout(saveStatusTimer);
|
||||
setSaveStatus(ok ? "saved" : "error");
|
||||
setSaveStatus(ok ? "saved" : (saveConflict ? "conflict" : "error"));
|
||||
/* een fout blijft staan tot een geslaagde save; succes ebt weg */
|
||||
if(ok) saveStatusTimer = setTimeout(()=>setSaveStatus("idle"), 2000);
|
||||
return ok;
|
||||
}
|
||||
/* niet weg-navigeren met onopgeslagen werk: geplande/lopende save of een
|
||||
mislukte laatste save betekent dat de server nog niet alles heeft */
|
||||
window.addEventListener("beforeunload", (e)=>{
|
||||
if(!canPersist()) return;
|
||||
if(saveTimer || saveInFlight || lastSaveError){ e.preventDefault(); e.returnValue = ""; }
|
||||
});
|
||||
/* autosave: door alles aan te roepen wat het bord of de woorden wijzigt */
|
||||
function scheduleSave(){
|
||||
if(!canPersist()) return;
|
||||
|
|
@ -478,7 +504,9 @@ async function hydrateFromServer(){
|
|||
return;
|
||||
}
|
||||
stopPupilView();
|
||||
const d = (await api("/me/data")).data || {};
|
||||
const res = await api("/me/data");
|
||||
const d = res.data || {};
|
||||
DATA_REV = typeof res.rev === "number" ? res.rev : 0;
|
||||
THEMES = themesFromData(d);
|
||||
GENERAL_EXTRA = generalExtraFromData(d);
|
||||
BS = boardsFromData(d);
|
||||
|
|
@ -663,6 +691,17 @@ document.getElementById("btnSave").addEventListener("click", async ()=>{
|
|||
loginWrap.classList.add("open");
|
||||
return;
|
||||
}
|
||||
/* na een conflict (409) niet blind overschrijven: de klik haalt eerst de
|
||||
nieuwste serverversie op, daarna kan er weer gewoon opgeslagen worden */
|
||||
if(saveConflict){
|
||||
saveConflict = false;
|
||||
lastSaveError = null;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
await hydrateFromServer();
|
||||
setSaveStatus("idle");
|
||||
return;
|
||||
}
|
||||
clearTimeout(saveTimer); /* expliciet opslaan gaat vóór de geplande autosave */
|
||||
await doSave();
|
||||
});
|
||||
|
|
|
|||
29
src/api.js
29
src/api.js
|
|
@ -153,16 +153,37 @@ export default async function api(app) {
|
|||
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
|
||||
app.get('/me/data', async (req, reply) => {
|
||||
need(req, reply);
|
||||
const r = await pool.query('SELECT data FROM users WHERE id = $1', [req.user.id]);
|
||||
return { data: r.rows[0]?.data ?? {} };
|
||||
const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]);
|
||||
return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) };
|
||||
});
|
||||
// Optimistische concurrency: de client stuurt de rev mee die hij geladen
|
||||
// heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders
|
||||
// 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check
|
||||
// (oude clients blijven werken, laatste schrijver wint dan zoals voorheen).
|
||||
const revFromRequest = (req) => {
|
||||
const h = req.headers['x-data-rev'];
|
||||
if (h === undefined) return null;
|
||||
const n = Number(h);
|
||||
return Number.isInteger(n) && n >= 0 ? n : null;
|
||||
};
|
||||
const writeUserData = async (req, reply, data) => {
|
||||
const rev = revFromRequest(req);
|
||||
const r = rev === null
|
||||
? await pool.query(
|
||||
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
|
||||
[data, req.user.id])
|
||||
: await pool.query(
|
||||
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 AND data_rev = $3 RETURNING data_rev',
|
||||
[data, req.user.id, rev]);
|
||||
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
|
||||
return { ok: true, rev: Number(r.rows[0].data_rev) };
|
||||
};
|
||||
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
|
||||
need(req, reply);
|
||||
const data = req.body ?? {};
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata');
|
||||
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot');
|
||||
await pool.query('UPDATE users SET data = $1 WHERE id = $2', [data, req.user.id]);
|
||||
return { ok: true };
|
||||
return writeUserData(req, reply, data);
|
||||
});
|
||||
|
||||
// ---- scholen (super) ---------------------------------------------------------
|
||||
|
|
|
|||
78
test/save-system.test.js
Normal file
78
test/save-system.test.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import api from '../src/api.js';
|
||||
|
||||
// Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join
|
||||
// doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de
|
||||
// test de rev-voorwaarde en het 409-pad kan controleren.
|
||||
function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params = []) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
|
||||
id: 7, username: 'docent', display_name: 'Docent', role: 'teacher',
|
||||
school_id: 2, class_id: null, data: {}, data_rev: dataRev,
|
||||
}] };
|
||||
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||
if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] };
|
||||
if (sql.startsWith('UPDATE users SET data =')) {
|
||||
return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] };
|
||||
}
|
||||
return { rows: [] };
|
||||
},
|
||||
};
|
||||
return (async () => {
|
||||
const app = Fastify({ trustProxy: 2 });
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { global: false });
|
||||
app.decorate('pg', pool);
|
||||
await app.register(api, { prefix: '/api' });
|
||||
await app.ready();
|
||||
return { app, calls };
|
||||
})();
|
||||
}
|
||||
|
||||
const sessionCookie = { teach_session: 'x'.repeat(64) };
|
||||
|
||||
test('GET /me/data geeft data én rev terug', async () => {
|
||||
const { app } = await makeApp({ dataRev: 3 });
|
||||
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
assert.deepEqual(res.json(), { data: { boards: {} }, rev: 3 });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('PUT /me/data met actuele rev slaagt en geeft de nieuwe rev', async () => {
|
||||
const { app, calls } = await makeApp({ dataRev: 3 });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
|
||||
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
assert.equal(res.json().rev, 4);
|
||||
const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data ='));
|
||||
assert.ok(upd.sql.includes('AND data_rev = $3'));
|
||||
assert.equal(upd.params[2], 3);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('PUT /me/data met verouderde rev geeft 409', async () => {
|
||||
const { app } = await makeApp({ dataRev: 5, updateSucceeds: false });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
|
||||
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
|
||||
assert.equal(res.statusCode, 409, res.body);
|
||||
assert.match(res.json().error, /ander tabblad/);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async () => {
|
||||
const { app, calls } = await makeApp({ dataRev: 3 });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
|
||||
payload: { boards: {} } });
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data ='));
|
||||
assert.ok(!upd.sql.includes('AND data_rev'));
|
||||
await app.close();
|
||||
});
|
||||
Loading…
Reference in a new issue