caterium-app/public/core/data-layer-v1773.js
pavlov346346-source 2e6c0e3939
Some checks failed
Caterium QA / qa (push) Failing after 8m8s
fix: stop re-sending every client on each start; drop rejected error-log records
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>
2026-09-21 16:48:16 +03:00

276 lines
27 KiB
JavaScript

(()=>{
'use strict';
if(window.CateriumDataV1773)return;
const VERSION='17.7.3';
const RELEASE='20260909-v17-7-3-clients-server-read';
const CLIENT_CACHE_KEY='cateriumClientsV1772';
const SERVER_CLIENT_CACHE_KEY='cateriumClientsServerV1773';
const LEGACY_LOYALTY_KEY='sunClientLoyaltyV1';
const LEGACY_COMM_KEY='sunClientCommunicationV1';
const listeners=new Map();
let tenantChanging=false,tenantEpoch=0;
const clone=value=>{try{return structuredClone(value)}catch(_){return JSON.parse(JSON.stringify(value))}};
const safeJson=(raw,fallback)=>{try{const value=JSON.parse(raw);return value==null?fallback:value}catch(_){return fallback}};
const emit=(topic,payload)=>{for(const fn of listeners.get(topic)||[]){try{fn(payload)}catch(error){console.error('[Caterium Data]',error)}}};
const subscribe=(topic,fn)=>{if(typeof fn!=='function')return()=>{};if(!listeners.has(topic))listeners.set(topic,new Set());listeners.get(topic).add(fn);return()=>listeners.get(topic)?.delete(fn)};
function runtimeOrders(){try{if(typeof orders!=='undefined'&&Array.isArray(orders))return orders}catch(_){}return null}
function runtimeCatalog(){try{if(typeof boxes!=='undefined'&&Array.isArray(boxes))return boxes}catch(_){}return null}
function runtimePersist(){try{return typeof persist==='function'?persist:null}catch(_){return null}}
function cloud(){return window.SunCloudV2||null}
function session(){try{return cloud()?.getSession?.()||null}catch(_){return null}}
function workspace(){try{return cloud()?.getWorkspace?.()||null}catch(_){return null}}
function supportMode(){try{return cloud()?.getSupportMode?.()||null}catch(_){return null}}
function isSignedIn(){return Boolean(session()?.user)}
function isSupportReadOnly(){return Boolean(supportMode()||cloud()?.isSupportMode?.())}
function canWrite(permission='app.write'){
if(isSupportReadOnly())return false;
if(!isSignedIn())return true;
try{const c=cloud();if(typeof c?.hasPermission==='function')return c.hasPermission(permission)||c.hasPermission('app.write')}catch(_){}
return true;
}
const storage={
read(key,fallback=null){const raw=localStorage.getItem(String(key));if(raw==null)return fallback;return safeJson(raw,fallback)},
write(key,value,{silent=false}={}){localStorage.setItem(String(key),JSON.stringify(value));if(!silent)emit(`storage:${key}`,clone(value));return value},
remove(key){localStorage.removeItem(String(key));emit(`storage:${key}`,null)},
raw(key){return localStorage.getItem(String(key))}
};
function persistDomain(key,list,persistLocal){
if(!persistLocal){storage.write(key,list,{silent:true});return}
const p=runtimePersist();
if(p){try{p();return}catch(error){console.warn('[Caterium Data] persist fallback',error)}}
storage.write(key,list,{silent:true});
}
function getOrders(){const live=runtimeOrders();return clone(live||storage.read('sunOrders',[])||[])}
function replaceOrders(next,{persistLocal=true,reason='replace'}={}){
const list=Array.isArray(next)?clone(next):[],live=runtimeOrders();
if(live){live.length=0;live.push(...clone(list))}
persistDomain('sunOrders',list,persistLocal);emit('orders',{reason,orders:clone(list)});
if(persistLocal)queueMicrotask(()=>window.CateriumDataV1773?.clients?.adoptLegacy?.({pushServer:true,reason:`orders:${reason}`}));
return list;
}
function getOrder(id){return getOrders().find(o=>String(o?.id)===String(id))||null}
function updateOrder(id,updater,{persistLocal=true,reason='update'}={}){
const list=getOrders(),index=list.findIndex(o=>String(o?.id)===String(id));if(index<0)return null;
const current=clone(list[index]),next=typeof updater==='function'?updater(current):{...current,...clone(updater||{})};if(!next)return null;
list[index]=next;replaceOrders(list,{persistLocal,reason});return clone(next);
}
function applyServerOrders(changed,{reason='server'}={}){
const patches=Array.isArray(changed)?changed:[];if(!patches.length)return {changed:0,ids:[]};
const list=getOrders(),map=new Map(list.map((o,i)=>[String(o?.id),i])),ids=[];
for(const patch of patches){const id=String(patch?.id??'');if(!id)continue;const index=map.get(id);if(index==null){list.push(clone(patch));map.set(id,list.length-1)}else list[index]=clone(patch);ids.push(id)}
replaceOrders(list,{persistLocal:false,reason});
try{window.render?.();window.renderOrders?.();window.renderStats?.();window.renderClients?.()}catch(_){}
try{window.dispatchEvent(new CustomEvent('caterium:data-orders-applied',{detail:{ids,reason}}))}catch(_){}
return {changed:ids.length,ids};
}
function getCatalog(){const live=runtimeCatalog();return clone(live||storage.read('sunBoxes',[])||[])}
function replaceCatalog(next,{persistLocal=true,reason='replace'}={}){
const list=Array.isArray(next)?clone(next):[],live=runtimeCatalog();
if(live){live.length=0;live.push(...clone(list))}
persistDomain('sunBoxes',list,persistLocal);emit('catalog',{reason,items:clone(list)});return list;
}
function getCatalogItem(id){return getCatalog().find(x=>String(x?.id)===String(id))||null}
function upsertCatalogItem(item,{persistLocal=true,reason='upsert'}={}){
if(!item?.id)return null;const list=getCatalog(),index=list.findIndex(x=>String(x?.id)===String(item.id)),next=clone(item);
if(index<0)list.push(next);else list[index]=next;replaceCatalog(list,{persistLocal,reason});return clone(next);
}
function removeCatalogItem(id,{persistLocal=true,reason='remove'}={}){
const list=getCatalog(),next=list.filter(x=>String(x?.id)!==String(id));if(next.length===list.length)return false;
replaceCatalog(next,{persistLocal,reason});return true;
}
const normalizePhone=value=>String(value||'').replace(/\D/g,'');
const normalizeName=value=>String(value||'').trim().replace(/\s+/g,' ').toLowerCase();
const clientKeyFromValues=(phone,name,orderId='')=>{const p=normalizePhone(phone);if(p)return `p:${p}`;const n=normalizeName(name);if(n)return `n:${n}`;return orderId!==''&&orderId!=null?`o:${String(orderId)}`:''};
const clientKeyFromOrder=order=>clientKeyFromValues(order?.phone,order?.contact,order?.id);
const orderStamp=order=>`${String(order?.date||'').padStart(10,'0')}T${String(order?.time||'').padStart(5,'0')}`;
function orderTotal(order){
const explicit=Number(order?.total);if(Number.isFinite(explicit))return explicit;
const catalog=getCatalog();return (Array.isArray(order?.lines)?order.lines:[]).reduce((sum,line)=>{const item=catalog.find(x=>String(x?.id)===String(line?.id));return sum+(window.CateriumPricing?.linePrice(line,item)??Number(line?.price??item?.price??0))*Number(line?.qty||0)},0);
}
function readObject(key){const value=storage.read(key,{});return value&&typeof value==='object'&&!Array.isArray(value)?value:{}}
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();
const ensure=key=>{if(!people.has(key)){const old=cached[key]||{};people.set(key,{key,name:String(old.name||''),phone:String(old.phone||''),latestAddress:String(old.latestAddress||''),addresses:new Set(),orderIds:[],orderCount:0,totalSpent:0,latestStamp:'',loyalty:null,communication:null,serverVersion:Number(old.serverVersion||0)||null,serverUpdatedAt:String(old.serverUpdatedAt||'')})}return people.get(key)};
for(const order of getOrders()){
const key=clientKeyFromOrder(order);if(!key)continue;const person=ensure(key),stamp=orderStamp(order),address=String(order?.address||'').trim();
person.orderIds.push(String(order?.id??''));person.orderCount++;if(String(order?.status||'')!=='Отменён')person.totalSpent+=Math.max(0,Number(orderTotal(order)||0));if(address)person.addresses.add(address);
if(!person.latestStamp||stamp>=person.latestStamp){person.latestStamp=stamp;person.name=String(order?.contact||person.name||'');person.phone=String(order?.phone||person.phone||'');person.latestAddress=address||person.latestAddress;}
}
const allKeys=new Set([...people.keys(),...Object.keys(cached),...Object.keys(loyalty),...Object.keys(communication)]);
for(const key of allKeys){const person=ensure(key);person.loyalty=Object.prototype.hasOwnProperty.call(loyalty,key)?clone(loyalty[key]):null;person.communication=Object.prototype.hasOwnProperty.call(communication,key)?clone(communication[key]):null;if(person.latestAddress)person.addresses.add(person.latestAddress)}
return [...people.values()].map(person=>({...person,addresses:[...person.addresses],orderIds:person.orderIds.filter(Boolean),totalSpent:Math.round(person.totalSpent*100)/100,latestStamp:undefined})).sort((a,b)=>(b.orderCount-a.orderCount)||String(a.name||a.key).localeCompare(String(b.name||b.key),'ru'));
}
function readServerClientCache(){const value=storage.read(SERVER_CLIENT_CACHE_KEY,null);return value&&typeof value==='object'&&!Array.isArray(value)?value:null}
function currentServerRows(){const ws=String(workspace()?.id||''),cache=readServerClientCache();if(!ws||!cache||String(cache.workspaceId||'')!==ws)return [];return Array.isArray(cache.rows)?cache.rows:[]}
function serverProfile(row){
const data=row?.data&&typeof row.data==='object'&&!Array.isArray(row.data)?row.data:{},identity=data.identity&&typeof data.identity==='object'?data.identity:data;
const key=String(row?.client_key||row?.canonical_key||'');if(!key)return null;
const latestAddress=String(row?.latest_address||identity?.latestAddress||data?.latestAddress||'');
return {key,name:String(row?.name||identity?.name||data?.name||''),phone:String(row?.phone||identity?.phone||data?.phone||''),latestAddress,addresses:latestAddress?[latestAddress]:[],orderIds:[],orderCount:0,totalSpent:0,loyalty:Object.prototype.hasOwnProperty.call(data,'loyalty')?clone(data.loyalty):null,communication:Object.prototype.hasOwnProperty.call(data,'communication')?clone(data.communication):null,serverVersion:Number(row?.version||0)||null,serverUpdatedAt:String(row?.updated_at||''),serverSourceKeys:Array.isArray(row?.source_keys)?row.source_keys.map(String):[],serverSourceCount:Number(row?.source_count||0)||1,canonicalRowPresent:Boolean(row?.canonical_row_present)};
}
function serverListClients(){return currentServerRows().map(serverProfile).filter(Boolean)}
function mergeClientSources(local,server){
local=local||buildClients();server=server||serverListClients();
const byKey=new Map(local.map(item=>[item.key,{...clone(item),dataSource:'local'}]));
for(const row of server){
if(row.key.startsWith('o:')&&!byKey.has(row.key))continue;
const current=byKey.get(row.key)||{key:row.key,name:'',phone:'',latestAddress:'',addresses:[],orderIds:[],orderCount:0,totalSpent:0,loyalty:null,communication:null};
const addresses=[...new Set([...(current.addresses||[]),...(row.addresses||[]),row.latestAddress].map(v=>String(v||'').trim()).filter(Boolean))];
byKey.set(row.key,{...current,name:row.name||current.name,phone:row.phone||current.phone,latestAddress:row.latestAddress||current.latestAddress,addresses,loyalty:row.loyalty??current.loyalty,communication:row.communication??current.communication,serverVersion:row.serverVersion,serverUpdatedAt:row.serverUpdatedAt,serverSourceKeys:row.serverSourceKeys,serverSourceCount:row.serverSourceCount,canonicalRowPresent:row.canonicalRowPresent,dataSource:'server+local'});
}
return [...byKey.values()].sort((a,b)=>(b.orderCount-a.orderCount)||String(a.name||a.key).localeCompare(String(b.name||b.key),'ru'));
}
function listClients(options={}){if(tenantChanging)return [];const source=typeof options==='string'?options:String(options?.source||'auto');if(source==='local'||source==='legacy')return clone(buildClients());if(source==='server')return clone(serverListClients());return clone(mergeClientSources())}
function compareClientSources(){
const local=buildClients(),server=serverListClients(),merged=mergeClientSources(local,server),serverKeys=new Set(server.map(x=>x.key)),localKeys=new Set(local.map(x=>x.key));
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'};
if(serverRefreshPromise)return serverRefreshPromise;if(!isSignedIn())return {status:'local',diagnostics:compareClientSources()};
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',diagnostics:compareClientSources()};
const cache=readServerClientCache(),age=cache?.fetchedAt?Date.now()-Date.parse(cache.fetchedAt):Infinity;if(!force&&String(cache?.workspaceId||'')===String(ws.id)&&age>=0&&age<15000)return {status:'cached',count:currentServerRows().length,diagnostics:compareClientSources()};
const epoch=tenantEpoch,userId=session()?.user?.id;
serverRefreshPromise=(async()=>{
try{
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};
}finally{serverRefreshPromise=null}
})();
return serverRefreshPromise;
}
function getClient(key){return listClients().find(x=>x.key===String(key))||null}
function writeLegacyMap(storageKey,key,value){
const map=readObject(storageKey);if(value==null)delete map[key];else map[key]=clone(value);
bridgeSuppressed=true;try{localStorage.setItem(storageKey,JSON.stringify(map))}finally{bridgeSuppressed=false}
return map;
}
function persistClientCacheFromList(list){const map={};for(const item of list||[])map[item.key]=cacheCore(item);writeClientCache(map);return map}
function changedClientKeys(before,next){const keys=new Set([...Object.keys(before||{}),...Object.keys(next||{})]),changed=[];for(const key of keys){if(JSON.stringify(before?.[key]||null)!==JSON.stringify(next?.[key]||null))changed.push(key)}return changed}
function adoptLegacy({pushServer=false,reason='legacy-adopt'}={}){
if(tenantChanging)return {changed:0,keys:[],clients:[]};
const before=readClientCache(),list=buildClients(),after={};for(const item of list)after[item.key]=cacheCore(item);const changed=changedClientKeys(before,after);writeClientCache(after);emit('clients',{reason,changedKeys:changed,clients:clone(list)});if(pushServer&&changed.length)scheduleServerPush(changed);return {changed:changed.length,keys:changed,clients:clone(list)};
}
function upsertClient(profile,{pushServer=true,reason='client.upsert'}={}){
const key=String(profile?.key||clientKeyFromValues(profile?.phone,profile?.name,profile?.orderId)||'');if(!key)return null;
const current=getClient(key)||{key,name:'',phone:'',latestAddress:'',loyalty:null,communication:null};const next={...current,...clone(profile),key};
if(Object.prototype.hasOwnProperty.call(profile||{},'loyalty'))writeLegacyMap(LEGACY_LOYALTY_KEY,key,next.loyalty);
if(Object.prototype.hasOwnProperty.call(profile||{},'communication'))writeLegacyMap(LEGACY_COMM_KEY,key,next.communication);
const cache=readClientCache();cache[key]=cacheCore(next);writeClientCache(cache);emit('clients',{reason,changedKeys:[key],clients:listClients()});if(pushServer)scheduleServerPush([key]);return clone(getClient(key)||next);
}
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'}}
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)}
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(){
if(window.__cateriumClientStorageBridgeV1772||typeof Storage==='undefined')return;window.__cateriumClientStorageBridgeV1772=true;
const proto=Storage.prototype,rawSet=proto.setItem,rawRemove=proto.removeItem;
proto.setItem=function(key,value){const result=rawSet.call(this,key,value);if(!bridgeSuppressed&&this===localStorage&&(key===LEGACY_LOYALTY_KEY||key===LEGACY_COMM_KEY))queueMicrotask(()=>window.CateriumDataV1773?.clients?.adoptLegacy?.({pushServer:true,reason:`legacy-write:${key}`}));return result};
proto.removeItem=function(key){const result=rawRemove.call(this,key);if(!bridgeSuppressed&&this===localStorage&&(key===LEGACY_LOYALTY_KEY||key===LEGACY_COMM_KEY))queueMicrotask(()=>window.CateriumDataV1773?.clients?.adoptLegacy?.({pushServer:true,reason:`legacy-remove:${key}`}));return result};
}
const ordersRepo={list:getOrders,get:getOrder,replace:replaceOrders,update:updateOrder,applyServerChanges:applyServerOrders};
const catalogRepo={list:getCatalog,get:getCatalogItem,replace:replaceCatalog,upsert:upsertCatalogItem,remove:removeCatalogItem};
const clientsRepo={list:listClients,get:getClient,upsert:upsertClient,setLoyalty:setClientLoyalty,setCommunication:setClientCommunication,adoptLegacy,push:pushClient,pushAll:pushAllClients,refreshServer:refreshServerClients,serverList:serverListClients,compare:compareClientSources,keyFromOrder:clientKeyFromOrder,keyFromValues:clientKeyFromValues,normalizePhone,normalizeName};
const diagnostics={
snapshot(){const clientCompare=compareClientSources();return {version:VERSION,workspaceId:workspace()?.id||null,signedIn:isSignedIn(),supportReadOnly:isSupportReadOnly(),orderCount:getOrders().length,catalogCount:getCatalog().length,clientCount:listClients().length,clientServerCount:clientCompare.serverCount,clientServerPreferred:clientCompare.serverPreferred,clientOrderMetricsEqual:clientCompare.orderMetricsEqual,listenerTopics:listeners.size,pendingClientPushes:pendingServerKeys.size}},
measure(name,fn){const started=performance.now();return Promise.resolve().then(fn).finally(()=>emit('metric',{name,durationMs:Math.round((performance.now()-started)*10)/10,at:new Date().toISOString()}))}
};
const api={VERSION,RELEASE,storage,orders:ordersRepo,catalog:catalogRepo,clients:clientsRepo,subscribe,emit,cloud,session,workspace,isSignedIn,isSupportReadOnly,canWrite,diagnostics};
window.CateriumDataV1773=api;
window.CateriumDataV1772=api;
window.CateriumDataV1771=api;
window.CateriumDataV1770=api;
window.CateriumData=api;
installLegacyClientBridge();
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(()=>{}).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(()=>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(_){}
})();