fix: stop re-sending every client on each start; drop rejected error-log records
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>
This commit is contained in:
pavlov346346-source 2026-09-21 16:48:16 +03:00
parent 5e72b23161
commit 2e6c0e3939
6 changed files with 159 additions and 16 deletions

View File

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"check:syntax": "node --check public/core/catalog-pricing.js && node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js && node --check public/core/proposal-layout.js && node --check public/core/mobile-order.js && node --check public/core/help-center.js",
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs",
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs && node tests/client-sync-v1780.mjs",
"check:release": "node tests/release-check.mjs",
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",
"test:db": "node tests/recovery/validate.mjs --smoke",

View File

@ -4524,7 +4524,12 @@ window.SUN_LEGACY_CATALOG_V175=[];
}
async function flushErrors(){
const c=client(),ws=workspace();if(flushBusy||!c||!networkReady())return;flushBusy=true;
try{const items=await idbAll('errors');for(const item of items.slice(0,20)){try{const {error}=await stabilityRpc(c.rpc('sun_v17_log_error',{p_workspace:item.workspaceId||ws?.id||null,p_client_id:item.clientId,p_app_version:item.version,p_level:item.level,p_message:item.message,p_stack:item.stack,p_context:item.context}),'Журнал ошибок');if(error)throw error;await idbDelete('errors',item.id);noteNetworkSuccess();}catch(e){if(networkish(e))noteNetworkFailure();break;}}}
try{const items=await idbAll('errors');for(const item of items.slice(0,20)){try{const {error}=await stabilityRpc(c.rpc('sun_v17_log_error',{p_workspace:item.workspaceId||ws?.id||null,p_client_id:item.clientId,p_app_version:item.version,p_level:item.level,p_message:item.message,p_stack:item.stack,p_context:item.context}),'Журнал ошибок');if(error)throw error;await idbDelete('errors',item.id);noteNetworkSuccess();}catch(e){
if(networkish(e)){noteNetworkFailure();break;}
// The server refused this record (e.g. it was logged in a workspace this user is not in). Retrying it forever would block every newer error, so give up on it.
const attempts=(Number(item.attempts)||0)+1,foreign=Boolean(item.workspaceId&&ws?.id&&item.workspaceId!==ws.id);
if(attempts>=3||foreign)await idbDelete('errors',item.id);else await idbPut('errors',{...item,attempts});
}}}
finally{flushBusy=false}
}

View File

@ -98,6 +98,13 @@
function cacheCore(profile){return {key:profile.key,name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||''),loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,serverVersion:Number(profile.serverVersion||0)||null,serverUpdatedAt:String(profile.serverUpdatedAt||'')}}
function readClientCache(){return readObject(CLIENT_CACHE_KEY)}
function writeClientCache(map){localStorage.setItem(CLIENT_CACHE_KEY,JSON.stringify(map||{}));return map}
// Fingerprint of what the server already holds for a client, so unchanged clients are never re-sent.
const PUSHED_KEY='cateriumClientsPushedV1780';
const stableJson=v=>v===null||typeof v!=='object'?JSON.stringify(v):Array.isArray(v)?'['+v.map(stableJson).join(',')+']':'{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+stableJson(v[k])).join(',')+'}';
function contentFp(p){const s=stableJson({n:String(p?.name||''),p:String(p?.phone||''),a:String(p?.latestAddress||''),l:p?.loyalty||null,c:p?.communication||null});let h=5381;for(let i=0;i<s.length;i++)h=((h<<5)+h+s.charCodeAt(i))|0;return (h>>>0).toString(36)+'.'+s.length}
const pushedSlot=(wsId,key)=>`${wsId}|${key}`;
const readPushed=()=>readObject(PUSHED_KEY);
function markPushed(wsId,key,fp){const map=readPushed(),slot=pushedSlot(wsId,key);if(map[slot]===fp)return;map[slot]=fp;storage.write(PUSHED_KEY,map,{silent:true})}
function buildClients(){
if(tenantChanging)return [];
const cached=readClientCache(),loyalty=readObject(LEGACY_LOYALTY_KEY),communication=readObject(LEGACY_COMM_KEY),people=new Map();
@ -137,6 +144,23 @@
const metricMismatches=local.map(item=>{const m=merged.find(x=>x.key===item.key);return !m||m.orderCount!==item.orderCount||Number(m.totalSpent)!==Number(item.totalSpent)?item.key:null}).filter(Boolean);
return {localCount:local.length,serverCount:server.length,mergedCount:merged.length,serverPreferred:server.length>0,missingOnServer:[...localKeys].filter(k=>!serverKeys.has(k)),serverOnly:[...serverKeys].filter(k=>!localKeys.has(k)),legacyServerFallbackRows:server.filter(x=>x.key.startsWith('o:')).length,orderMetricsEqual:metricMismatches.length===0,metricMismatches};
}
// When the server already holds exactly what this device has, remember that (and its version) instead of re-sending it.
function seedPushedFromServer(wsId,rows){
const local=new Map(buildClients().map(p=>[p.key,p])),pushed=readPushed(),cache=readClientCache();let pushedDirty=false,cacheDirty=false;
for(const row of rows){
const srv=serverProfile(row),mine=srv&&local.get(srv.key);if(!mine)continue;
const fp=contentFp(mine);if(fp!==contentFp(srv))continue;
const slot=pushedSlot(wsId,srv.key);if(pushed[slot]!==fp){pushed[slot]=fp;pushedDirty=true}
const entry=cache[srv.key];if(entry&&srv.serverVersion&&Number(entry.serverVersion||0)!==srv.serverVersion){cache[srv.key]={...entry,serverVersion:srv.serverVersion,serverUpdatedAt:srv.serverUpdatedAt};cacheDirty=true}
}
if(pushedDirty)storage.write(PUSHED_KEY,pushed,{silent:true});if(cacheDirty)writeClientCache(cache);
}
// Only used when nothing was ever sent for this client from here: the last server snapshot may already hold the same content.
function serverHolds(wsId,key,fp){
const cache=readServerClientCache();if(!cache||String(cache.workspaceId||'')!==String(wsId)||!Array.isArray(cache.rows))return false;
const row=cache.rows.find(r=>String(r?.client_key||r?.canonical_key||'')===key),srv=row&&serverProfile(row);
return Boolean(srv&&contentFp(srv)===fp);
}
let serverRefreshPromise=null;
async function refreshServerClients({force=false,reason='manual'}={}){
if(tenantChanging)return {status:'switching'};
@ -149,6 +173,7 @@
const {data,error}=await c.rpc('sun_v17_clients_snapshot_v1773',{p_workspace:ws.id});if(error)throw error;const rows=Array.isArray(data)?data:[];
if(epoch!==tenantEpoch||tenantChanging||workspace()?.id!==ws.id||session()?.user?.id!==userId)return {status:'stale'};
storage.write(SERVER_CLIENT_CACHE_KEY,{workspaceId:String(ws.id),fetchedAt:new Date().toISOString(),rows:clone(rows)},{silent:true});
try{seedPushedFromServer(String(ws.id),rows)}catch(_){}
const diagnostics=compareClientSources();emit('clients',{reason:`server:${reason}`,changedKeys:rows.map(r=>String(r?.client_key||'')).filter(Boolean),clients:listClients(),diagnostics});
try{window.dispatchEvent(new CustomEvent('caterium:clients-server-refresh',{detail:{reason,count:rows.length,diagnostics}}))}catch(_){}
return {status:'loaded',count:rows.length,diagnostics};
@ -177,21 +202,44 @@
}
function setClientLoyalty(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_LOYALTY_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.loyalty'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.loyalty||null)}
function setClientCommunication(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_COMM_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.communication'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.communication||null)}
function serverPayload(profile){return {schemaVersion:1,key:profile.key,identity:{name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||'')},loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,source:'data-layer-v1772',updatedAt:new Date().toISOString()}}
async function pushClient(key,knownProfile){
function serverPayload(profile){return {schemaVersion:1,key:profile.key,identity:{name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||'')},loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,source:'data-layer-v1772'}}
const pushInflight=new Map();
async function pushClient(key,knownProfile,{force=false}={}){
if(tenantChanging)return {status:'switching',key};
const profile=knownProfile||getClient(key);if(!profile)return {status:'missing',key};if(!isSignedIn())return {status:'local',key};if(isSupportReadOnly()||!canWrite('clients.edit'))return {status:'read-only',key};
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',key};
const fp=contentFp(profile),slot=pushedSlot(ws.id,key);
// Never re-send unchanged content, and never run two saves of one client at once: the second would carry a stale version and conflict with the first.
for(;;){
if(!force){
const known=readPushed()[slot];
if(known===fp)return {status:'unchanged',key};
if(known===undefined&&serverHolds(ws.id,key,fp)){markPushed(ws.id,key,fp);return {status:'unchanged',key}}
}
const running=pushInflight.get(slot);if(!running)break;
if(running.fp===fp)return running.promise;
await running.promise.catch(()=>{});if(tenantChanging)return {status:'switching',key};
}
const promise=(async()=>{
const epoch=tenantEpoch,userId=session()?.user?.id;
const cached=readClientCache()[key]||{},expected=Number(cached.serverVersion||0)||null;
const {data,error}=await c.rpc('sun_v17_save_client_v1772',{p_workspace:ws.id,p_client_key:key,p_profile:serverPayload(profile),p_expected_version:expected,p_client_id:cloud()?.getClientId?.()||'browser'});if(error)throw error;
if(epoch!==tenantEpoch||tenantChanging||workspace()?.id!==ws.id||session()?.user?.id!==userId)return {status:'stale',key};
const row=Array.isArray(data)?data[0]:data;if(row){const map=readClientCache();map[key]={...(map[key]||cacheCore(profile)),serverVersion:Number(row.version||0)||null,serverUpdatedAt:String(row.updated_at||'')};writeClientCache(map)}
markPushed(ws.id,key,fp);
return {status:'saved',key,version:Number(row?.version||0)||null};
})();
pushInflight.set(slot,{fp,promise});
try{return await promise}finally{if(pushInflight.get(slot)?.promise===promise)pushInflight.delete(slot)}
}
const pendingServerKeys=new Set();let serverPushTimer=0;
function scheduleServerPush(keys){for(const key of keys||[])if(key)pendingServerKeys.add(String(key));if(serverPushTimer)return;serverPushTimer=setTimeout(async()=>{serverPushTimer=0;const keys=[...pendingServerKeys];pendingServerKeys.clear();for(const key of keys){try{await pushClient(key)}catch(error){console.warn('[Caterium Clients] server save failed',key,error?.message||error)}}},250)}
async function pushAllClients(){const result=[],epoch=tenantEpoch,workspaceId=workspace()?.id;for(const profile of listClients()){if(tenantChanging||epoch!==tenantEpoch||workspace()?.id!==workspaceId)break;try{result.push(await pushClient(profile.key,profile))}catch(error){result.push({status:'error',key:profile.key,error:String(error?.message||error)})}}return result}
let pushAllPromise=null;
function pushAllClients(){
if(pushAllPromise)return pushAllPromise;
pushAllPromise=(async()=>{const result=[],epoch=tenantEpoch,workspaceId=workspace()?.id;for(const profile of listClients()){if(tenantChanging||epoch!==tenantEpoch||workspace()?.id!==workspaceId)break;try{result.push(await pushClient(profile.key,profile))}catch(error){result.push({status:'error',key:profile.key,error:String(error?.message||error)})}}return result})().finally(()=>{pushAllPromise=null});
return pushAllPromise;
}
let bridgeSuppressed=false;
function installLegacyClientBridge(){
@ -219,9 +267,9 @@
window.addEventListener('sun:cloud-tenant-changing',()=>{tenantChanging=true;tenantEpoch++;clearTimeout(serverPushTimer);serverPushTimer=0;pendingServerKeys.clear()});
window.addEventListener('sun:cloud-state-applied',()=>{tenantChanging=false});
adoptLegacy({pushServer:false,reason:'boot'});
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{clientsRepo.refreshServer({force:true,reason:'permissions'}).catch(()=>{});clientsRepo.pushAll().catch(()=>{})},800));
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{clientsRepo.refreshServer({force:true,reason:'permissions'}).catch(()=>{}).then(()=>clientsRepo.pushAll()).catch(()=>{})},800));
window.addEventListener('sun:cloud-sync-complete',()=>setTimeout(()=>{clientsRepo.adoptLegacy({pushServer:true,reason:'cloud-sync'});clientsRepo.refreshServer({force:true,reason:'cloud-sync'}).catch(()=>{})},500));
setTimeout(()=>clientsRepo.refreshServer({reason:'boot'}).catch(()=>{}),1400);
setTimeout(()=>clientsRepo.pushAll().catch(()=>{}),3500);
setTimeout(()=>Promise.resolve(serverRefreshPromise).catch(()=>{}).then(()=>clientsRepo.pushAll()).catch(()=>{}),3500);
try{window.dispatchEvent(new CustomEvent('caterium:data-ready',{detail:{version:VERSION,phase:4,clients:true,clientServerRead:true}}))}catch(_){}
})();

View File

@ -1,7 +1,7 @@
(()=>{
'use strict';
const VERSION='17.7.3';
const RELEASE='20260918-ui-stability-20260919-client-menu';
const RELEASE='20260921-client-sync';
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
function installAuthBoot(){

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,90 @@
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);