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= 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);