diff --git a/package.json b/package.json index 576332e..8b73c0d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/app-runtime.js b/public/app-runtime.js index d91a86c..4af2845 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -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} } diff --git a/public/core/data-layer-v1773.js b/public/core/data-layer-v1773.js index cca3763..ab99e73 100644 --- a/public/core/data-layer-v1773.js +++ b/public/core/data-layer-v1773.js @@ -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>>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 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)} - return {status:'saved',key,version:Number(row?.version||0)||null}; + 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(_){} })(); diff --git a/public/core/performance.js b/public/core/performance.js index 48d68e3..90c5aa4 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -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(){ diff --git a/public/index.html b/public/index.html index d65f319..40abf6a 100644 --- a/public/index.html +++ b/public/index.html @@ -71,7 +71,7 @@ button{touch-action:manipulation} #sunGlobalSearchBtn kbd{display:none!important} #sunSyncSettingsCard>div[style*="grid-template-columns"]{grid-template-columns:1fr!important} -}
Caterium

Стоимость позиций0 ₽

Предоплата0 ₽

К оплате0 ₽

Заказы

МероприятиеДата и времяАдресСуммаСтатусДействия

Нажмите «Изменить», чтобы открыть заказ. Сумма, предоплата и остаток сохраняются вместе с заказом.

Склад

В этой версии каталог продуктов пополняется из составов боксов. Остатки и цены добавим следующим шагом.

Статистика

- + diff --git a/tests/client-sync-v1780.mjs b/tests/client-sync-v1780.mjs new file mode 100644 index 0000000..2c4d03b --- /dev/null +++ b/tests/client-sync-v1780.mjs @@ -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= 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);