Some checks failed
Caterium QA / qa (push) Failing after 8m8s
Clients: every page load re-sent all clients (72 requests for 18 clients) because pushAll ran from several startup events at once, never skipped unchanged clients, and overlapping saves of one client read the same stale version, so ~40% ended in 409 conflicts. The payload also carried a fresh updatedAt, so even identical re-sends bumped the server version and wrote a change event, which made other devices' next save conflict too. - Remember what the server holds per client (content fingerprint, scoped to the workspace) and skip unchanged clients; seed it from the server snapshot so a device that is already in sync sends nothing. - Serialise saves per client and make pushAll single-flight. - Load the server snapshot before the startup push instead of racing it. - Drop the volatile updatedAt from the payload (server keeps updated_at). Error log: a record the server refuses (Access denied for a workspace the user is not in) stayed in the IndexedDB queue forever, was re-sent on every flush and could block newer records behind it. Records from another workspace are now dropped, others after 3 attempts. Adds tests/client-sync-v1780.mjs (fake server enforcing the SQL conflict rule; fails on the old module) to test:static, and bumps the cache-busting versions of performance.js / app-runtime.js. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
91 lines
6.0 KiB
JavaScript
91 lines
6.0 KiB
JavaScript
import fs from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
// Behaviour test for the clients sync in core/data-layer-v1773.js, run against a fake server that
|
|
// enforces the same version-conflict rule as sun_v17_save_client_v1772.
|
|
// DATA_LAYER=<path> runs it against another copy of the module (e.g. an older revision).
|
|
const file=process.env.DATA_LAYER||'public/core/data-layer-v1773.js';
|
|
const code=fs.readFileSync(file,'utf8');
|
|
const realSetTimeout=globalThis.setTimeout;
|
|
globalThis.setTimeout=(fn,ms=0,...a)=>realSetTimeout(fn,Math.ceil(ms/10),...a);
|
|
const wait=ms=>new Promise(r=>realSetTimeout(r,ms));
|
|
|
|
const store=new Map();
|
|
globalThis.localStorage={getItem:k=>store.has(k)?store.get(k):null,setItem:(k,v)=>{store.set(k,String(v))},removeItem:k=>{store.delete(k)}};
|
|
globalThis.window=globalThis;
|
|
let handlers={};
|
|
window.addEventListener=(type,fn)=>{(handlers[type]||(handlers[type]=[])).push(fn)};
|
|
window.dispatchEvent=()=>true;
|
|
const fire=type=>(handlers[type]||[]).forEach(fn=>fn());
|
|
globalThis.CustomEvent??=class{constructor(type,init){this.type=type;this.detail=init?.detail}};
|
|
|
|
function makeServer(){
|
|
const rows=new Map(),saves=[],conflicts=[];
|
|
async function rpc(name,args){
|
|
await wait(2);
|
|
if(name==='sun_v17_clients_snapshot_v1773')return {data:[...rows].map(([key,v])=>({client_key:key,version:v.version,data:v.data,updated_at:'2026-01-01T00:00:00Z'})),error:null};
|
|
if(name!=='sun_v17_save_client_v1772')return {data:null,error:{message:'unknown rpc '+name}};
|
|
const key=args.p_client_key,cur=rows.get(key);saves.push({key,expected:args.p_expected_version});
|
|
if(cur&&args.p_expected_version!=null&&cur.version!==args.p_expected_version){conflicts.push(key);return {data:null,error:{code:'40001',message:`SUN_CLIENT_CONFLICT expected=${args.p_expected_version} actual=${cur.version}`}}}
|
|
const same=cur&&JSON.stringify(cur.data)===JSON.stringify(args.p_profile);
|
|
const next={version:cur?(same?cur.version:cur.version+1):1,data:args.p_profile};rows.set(key,next);
|
|
return {data:[{client_key:key,version:next.version,data:next.data,updated_at:new Date().toISOString()}],error:null};
|
|
}
|
|
return {rpc,rows,saves,conflicts};
|
|
}
|
|
|
|
function boot(server){
|
|
for(const k of Object.keys(window))if(/^CateriumData/.test(k)||k==='__cateriumClientStorageBridgeV1772')delete window[k];
|
|
handlers={};
|
|
window.SunCloudV2={getSession:()=>({user:{id:'u1'}}),getWorkspace:()=>({id:'ws1'}),getClient:()=>({rpc:server.rpc}),getClientId:()=>'test',hasPermission:()=>true,getSupportMode:()=>null,isSupportMode:()=>false};
|
|
vm.runInThisContext(code);
|
|
return window.CateriumDataV1773;
|
|
}
|
|
// What production does while starting up: permission and sync events arrive several times.
|
|
async function startupEvents(){fire('sun:cloud-permissions-changed');fire('sun:cloud-sync-complete');fire('sun:cloud-permissions-changed');fire('sun:cloud-sync-complete');await wait(900)}
|
|
|
|
const failures=[];
|
|
const check=(name,ok,detail='')=>{if(ok)console.log('OK:',name);else{failures.push(name);console.error('FAIL:',name,detail)}};
|
|
|
|
const KEYS=['p:79110000001','p:79110000002','p:79110000003','p:79110000004','p:79110000005'];
|
|
store.set('cateriumClientsV1772',JSON.stringify(Object.fromEntries(KEYS.map((key,i)=>[key,{key,name:'Client '+i,phone:key.slice(2),latestAddress:'',loyalty:null,communication:null,serverVersion:null,serverUpdatedAt:''}]))));
|
|
|
|
// 1. Empty server, several startup events: every client is sent once, without conflicting with itself.
|
|
let server=makeServer();
|
|
let api=boot(server);await startupEvents();
|
|
check('first start sends each client exactly once',server.saves.length===KEYS.length,`saves=${server.saves.length}`);
|
|
check('first start produces no version conflicts',server.conflicts.length===0,`conflicts=${server.conflicts.length}`);
|
|
|
|
// 2. Restart with the server already up to date: nothing is sent.
|
|
server.saves.length=0;server.conflicts.length=0;
|
|
api=boot(server);await startupEvents();
|
|
check('restart with unchanged data sends nothing',server.saves.length===0,`saves=${server.saves.length}`);
|
|
|
|
// 3. A real edit is sent once, with the current version.
|
|
const before=server.rows.get(KEYS[0]).version;
|
|
api.clients.setLoyalty(KEYS[0],{tier:'gold'});await wait(200);
|
|
check('editing one client sends exactly one save',server.saves.length===1&&server.saves[0].key===KEYS[0],JSON.stringify(server.saves));
|
|
check('the edit bumps the server version once',server.rows.get(KEYS[0]).version===before+1);
|
|
// Reverting to what an older server snapshot still holds must be sent too.
|
|
server.saves.length=0;
|
|
api.clients.setLoyalty(KEYS[0],null);await wait(200);
|
|
check('reverting an edit is sent as well',server.saves.length===1&&server.rows.get(KEYS[0]).data.loyalty===null,JSON.stringify(server.saves));
|
|
|
|
// 4. Parallel saves of one client are serialised instead of racing on a stale version.
|
|
server.saves.length=0;server.conflicts.length=0;
|
|
const profile=api.clients.get(KEYS[1]);
|
|
const [a,b,c]=await Promise.all([api.clients.push(KEYS[1],{...profile,name:'Parallel'}),api.clients.push(KEYS[1],{...profile,name:'Parallel'}),api.clients.push(KEYS[1],{...profile,name:'Parallel again'})]);
|
|
check('identical parallel saves are merged into one request',server.saves.filter(s=>s.key===KEYS[1]).length===2,`saves=${server.saves.length}`);
|
|
check('parallel saves of different content do not conflict',server.conflicts.length===0&&[a,b,c].every(r=>r.status==='saved'),JSON.stringify([a,b,c]));
|
|
|
|
// 5. Startup right after a change made on another device: the server row is adopted, not overwritten by a blind re-send.
|
|
const other=makeServer();
|
|
for(const [i,key] of KEYS.entries())other.rows.set(key,{version:3,data:{schemaVersion:1,key,identity:{name:'Client '+i,phone:key.slice(2),latestAddress:''},loyalty:null,communication:null,source:'data-layer-v1772'}});
|
|
store.delete('cateriumClientsPushedV1780');
|
|
api=boot(other);await startupEvents();
|
|
check('start against an already-synced server sends nothing',other.saves.length===0,`saves=${other.saves.length}`);
|
|
|
|
globalThis.setTimeout=realSetTimeout;
|
|
if(failures.length){console.error(`${failures.length} client-sync check(s) failed`);process.exit(1)}
|
|
process.exit(0);
|