@murumets-ee/yhikas-sync 0.92.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- import{C as e,D as t,E as n,T as r,b as i,k as a,w as o,x as s}from"./sync-state-table-BrcoCu-Z.mjs";import{createHash as c}from"node:crypto";import{z as l}from"zod";var u=class extends Error{counts;constructor(e,t){super(`${t.failed} of ${t.created+t.updated+t.retired+t.failed} ${e} writes failed. The run is NOT recorded as successful, so the staleness watchdog stays armed and the next run retries from scratch.`),this.name=`YhikasApplyPartialError`,this.counts=t}};function d(e){if(e instanceof Date)return Number.isNaN(e.getTime())?null:e;if(typeof e==`string`){let t=new Date(e);return Number.isNaN(t.getTime())?null:t}return null}async function f(e,t,n){return(await e.findMany({limit:n})).map(e=>({id:String(e.id),key:String(e[t]),status:String(e.status),sourceHash:typeof e.sourceHash==`string`?e.sourceHash:null,publishedAt:d(e.publishedAt)}))}async function p(e){let{resource:t,plan:n,client:r,secondaryLocale:i,logger:a}=e,o={created:0,updated:0,retired:0,unchanged:n.unchanged.length,failed:0};for(let e of n.create)try{let t=await r.create({...e.base,status:`draft`});await h(r,String(t.id),e,i),await m(r,String(t.id),e.hash,null),o.created+=1}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to create a synced row`)}for(let{local:e,row:s}of n.update)try{await r.update(e.id,{...s.base}),await h(r,e.id,s,i),await m(r,e.id,s.hash,e.publishedAt),o.updated+=1}catch(e){o.failed+=1,a.error({err:e,resource:t,key:s.key},`yhikas-sync: failed to update a synced row`)}for(let e of n.retire)try{await r.updateForLocale(e.id,{status:`draft`},i),await r.update(e.id,{status:`draft`}),o.retired+=1,a.warn({resource:t,key:e.key},`yhikas-sync: retiring a row that upstream no longer offers as publishable content`)}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to retire a synced row`)}if(o.failed>0)throw new u(t,o);return o}async function m(e,t,n,r){await e.update(t,{status:`published`,publishedAt:r??new Date,sourceHash:n})}async function h(e,t,n,r){if(n.secondary){await e.updateForLocale(t,{...n.secondary},r),await e.updateForLocale(t,{status:`published`},r);return}await e.updateForLocale(t,{status:`draft`},r),await e.deleteTranslation(t,r)}var g=class extends Error{resource;kind;status;constructor(e,t,n,r){super(n,r?.cause===void 0?void 0:{cause:r.cause}),this.name=`YhikasUpstreamError`,this.resource=e,this.kind=t,this.status=r?.status}},_=class extends Error{resource;reason;constructor(e,t,n){super(n),this.name=`YhikasSyncRefusedError`,this.resource=e,this.reason=t}};function v(e){return c(`sha256`).update(y(e)).digest(`hex`)}function y(e){return e===null?`null`:e===void 0?`undefined`:Array.isArray(e)?`[${e.map(y).join(`,`)}]`:typeof e==`object`?`{${Object.entries(e).filter(([,e])=>e!==void 0).sort(([e],[t])=>e<t?-1:+(e>t)).map(([e,t])=>`${JSON.stringify(e)}:${y(t)}`).join(`,`)}}`:JSON.stringify(e)}function b(e){let{resource:t,upstream:n,local:r,keyOf:i,hashOf:a,floor:o}=e;if(n.length>o.maxRows)throw new _(t,`oversized-snapshot`,`yhikas-admin returned ${n.length} ${t} rows, above the ${o.maxRows} ceiling. Refusing the run rather than processing a prefix — a truncated snapshot would read as ${n.length-o.maxRows} deletions.`);let s=new Map;for(let e of n){let n=i(e);if(s.has(n))throw new _(t,`duplicate-key`,`yhikas-admin returned two ${t} rows with the same business key '${n}'. That key is unique upstream, so the response is not a faithful snapshot.`);s.set(n,e)}let c=new Map;for(let e of r){if(c.has(e.key))throw new _(t,`duplicate-key`,`Local ${t} content holds two rows keyed '${e.key}'. Refusing to guess which is authoritative.`);c.set(e.key,e)}let l=[],u=[],d=[];for(let[e,t]of s){let n=c.get(e);if(!n){l.push(t);continue}n.sourceHash===a(t)&&n.status===`published`?d.push(n):u.push({local:n,row:t})}let f=r.filter(e=>e.status===`published`&&!s.has(e.key));return ee(t,n.length,r,f,o),{create:l,update:u,unchanged:d,retire:f}}function ee(e,t,n,r,i){if(r.length===0)return;if(t===0)throw new _(e,`empty-snapshot`,`yhikas-admin returned zero ${e} rows while ${r.length} are published locally. Refusing to retire content on the strength of an empty snapshot — that is how a partial upstream response becomes a wiped price sheet.`);let a=n.filter(e=>e.status===`published`).length;if(a<i.minRowsForFraction)return;let o=r.length/a;if(o>i.maxRetireFraction)throw new _(e,`retire-fraction`,`This run would retire ${r.length} of ${a} published ${e} rows (${Math.round(o*100)}%), above the ${Math.round(i.maxRetireFraction*100)}% floor. At this change rate a run proposing to retire most of the set is far likelier to be a bug than a business event.`)}function te(e,t){if(t.trim().length===0)throw TypeError(`yhikas-sync: defaultLocale must be a non-empty locale code`);return{findMany:t=>e.findMany(t),create:t=>e.create(t),update:(t,n)=>e.update(t,n),updateForLocale:(n,r,i)=>e.updateForLocale(n,r,i,{defaultLocale:t}),deleteTranslation:(t,n)=>e.deleteTranslation(t,n)}}const x=[`et`,`en`];function S(e){if(!x.includes(e))throw TypeError(`yhikas-sync: unsupported locale '${e}' — upstream carries only 'et' and 'en', and projecting one language's text under another locale would publish the wrong language while every write succeeded.`)}function C(e){return S(e),e===`et`?`en`:`et`}function w(e,t){return t===`et`?e.et:e.en}function T(e,t){return t===`et`?e.htmlContentEt:e.htmlContentEn}function E(e){return typeof e==`string`&&e.trim().length>0}function D(e){return E(e)?e.trim():``}const ne=/[\s:/\\<>"'`?#&%]/;function O(e){return!ne.test(e)&&!/[\u0000-\u001f\u007f]/.test(e)}function k(e,t,n,r){if(!(t===0||n>0))throw new _(e,`empty-content`,`yhikas-admin returned ${t} ${e} row(s) and ${n} of them are publishable — every one was skipped. One unusable row is an editorial gap and is skipped on its own; a whole snapshot of them is what a renamed column or a changed envelope looks like from here, and unknown keys are stripped by design so the schema cannot tell the difference. Refusing rather than reporting a successful run that published nothing: local content is unchanged and the staleness watchdog stays armed. Reasons: ${r.slice(0,A).map(e=>`${e.key}: ${e.reason}`).join(`; `)}`)}const A=3;function j(e,t){S(t.defaultLocale);let n=C(t.defaultLocale),r=[],i=[];for(let a of e){if(a.code.length>190){i.push({key:a.code.slice(0,80),reason:`upstream code exceeds the 190-character column`});continue}let e=w(a.name,t.defaultLocale);if(!E(e)){i.push({key:a.code,reason:`no '${t.defaultLocale}' name upstream — a room type with no name in the site's primary language has nothing publishable to render`});continue}let o=w(a.name,n),s={code:a.code,name:e,totalArea:a.totalArea,livingArea:a.livingArea,commonArea:a.commonArea,capacity:a.capacity,placesOccupied:a.placesOccupied,monthlyRent:a.monthlyRent,discountedMonthlyRent:a.discountedRent,dailyRent:a.dailyRent,currency:`EUR`,vatTreatment:`net`,hasEnglish:E(a.name.en)},c=E(o)?{name:o}:null;r.push({key:a.code,base:s,secondary:c,hash:P(s,c)})}return k(o,e.length,r.length,i),{projected:r,skipped:i}}function M(e,t,n){S(t.defaultLocale);let r=C(t.defaultLocale),i=[],a=[];for(let o of e){let e=w(o.title,t.defaultLocale),s=n(T(o,t.defaultLocale));if(!E(e)||!E(s)){a.push({key:o.type,reason:`no publishable '${t.defaultLocale}' title or body upstream`});continue}if(o.type.length>190||o.slug.length>190){a.push({key:o.type.slice(0,80),reason:`upstream type or slug exceeds the 190-character column — refusing this row rather than letting the write fail and hold the whole resource in failure`});continue}if(!O(o.slug)){a.push({key:o.type,reason:`upstream slug ${JSON.stringify(o.slug)} is not usable as an anchor fragment — it carries a scheme, a path separator, whitespace or markup. Refusing this row rather than the whole response: upstream derives the slug from an unvalidated free-text field, so one bad value must not take the resource offline`});continue}let c=w(o.title,r),l=n(T(o,r)),u=n(o.htmlContentEn),d={type:o.type,title:e,sourceSlug:o.slug,body:s,order:o.order,hasEnglish:E(o.title.en)&&E(u)},f=E(c)&&E(l)?{title:c,body:l}:null;i.push({key:o.type,base:d,secondary:f,hash:P(d,f)})}return k(s,e.length,i.length,a),{projected:i,skipped:a}}function N(e,n){S(n.defaultLocale);let i=C(n.defaultLocale),a=e.notices.filter(e=>e.isActive).sort((e,t)=>e.order-t.order),o=a.slice(0,25),s=[];a.length>o.length&&s.push({key:r,reason:`${a.length-o.length} active notice(s) beyond the 25 ticker ceiling were dropped — a marquee past a couple of dozen entries is not read, and an unbounded array from upstream would ride onto every page of the site`});let c=e=>o.map(t=>w(t.text,e)).filter(E),l=w(e.receptionHours,n.defaultLocale),u=c(n.defaultLocale);if(!E(l)&&u.length===0)throw new _(t,`empty-content`,`yhikas-admin returned a well-formed site-info response carrying no usable '${n.defaultLocale}' content: reception hours are empty, and of ${e.notices.length} notice(s) upstream, ${a.length} are active and ${u.length} have '${n.defaultLocale}' text. (Those three counts are reported separately on purpose — "no active notices" and "active notices with no text in this language" send an operator to different places.) That state is exactly what the endpoint returns when no row exists at all, and it is indistinguishable from a deliberate clearing — so it is refused rather than applied. Local content is unchanged and the staleness watchdog stays armed. Note this refusal is TERMINAL, not transient: it will repeat every run until upstream carries something. If the intent really was to clear everything, leave one of the two set.`);let d=w(e.receptionHours,i),f=c(i),p=w(e.receptionHours,`en`),m=c(`en`),h={key:r,receptionHours:D(l),notices:u,hasEnglish:E(p)||m.length>0},g=E(d)||f.length>0?{receptionHours:D(d),notices:f}:null;return{projected:[{key:r,base:h,secondary:g,hash:P(h,g)}],skipped:s}}function P(e,t){return v({base:e,secondary:t})}var F=class extends Error{summary;constructor(e){let t=e.outcomes.filter(e=>!e.ok);super(`yhikas-admin sync failed for ${t.map(e=>e.resource).join(`, `)}: `+t.map(e=>e.error).join(` | `)),this.name=`YhikasSyncRunError`,this.summary=e}};async function re(e){let n=e.now??(()=>new Date),r=C(e.defaultLocale),i=[];i.push(await I({resource:o,keyField:`code`,client:e.roomTypeClient,fetchAndProject:async()=>j(await e.upstream.fetchRoomTypes(),{defaultLocale:e.defaultLocale}),deps:e,secondaryLocale:r,now:n})),i.push(await I({resource:s,keyField:`type`,client:e.legalDocumentClient,fetchAndProject:async()=>M(await e.upstream.fetchLegalDocuments(),{defaultLocale:e.defaultLocale},e.sanitizeHtml),deps:e,secondaryLocale:r,now:n})),i.push(await I({resource:t,keyField:`key`,client:e.siteInfoClient,fetchAndProject:async()=>N(await e.upstream.fetchSiteInfo(),{defaultLocale:e.defaultLocale}),deps:e,secondaryLocale:r,now:n}));let a={outcomes:i,ok:i.every(e=>e.ok)};if(!a.ok)throw new F(a);return a}async function I(e){let{resource:t,keyField:n,client:r,fetchAndProject:i,deps:a,secondaryLocale:o,now:s}=e,{logger:c,state:l,floor:u}=a;try{await l.ensure(t,s()),await l.recordAttempt(t,s());let e=await i();for(let n of e.skipped.slice(0,20))c.warn({resource:t,key:n.key,reason:n.reason},`yhikas-sync: skipping an upstream row, or part of one, that cannot be published`);e.skipped.length>20&&c.warn({resource:t,suppressed:e.skipped.length-20,total:e.skipped.length},`yhikas-sync: further skipped rows not logged individually`);let a=await f(r,n,u.maxRows),d=await p({resource:t,plan:b({resource:t,upstream:e.projected,local:a,keyOf:e=>e.key,hashOf:e=>e.hash,floor:u}),client:r,secondaryLocale:o,logger:c});return await l.recordSuccess(t,s(),d),c.info({resource:t,...d,skipped:e.skipped.length},`yhikas-sync: resource synced`),{resource:t,ok:!0,counts:d,skipped:e.skipped.length,error:null}}catch(e){let n=e instanceof Error?e.message:String(e);c.error({err:e,resource:t},`yhikas-sync: resource sync failed — local content unchanged`);try{await l.recordFailure(t,s(),n)}catch(e){c.error({err:e,resource:t},`yhikas-sync: could not record the failure in yhikas_sync_state`)}return{resource:t,ok:!1,counts:null,skipped:0,error:n}}}const L=1024;function R(e){return e.length<=L?e:`${e.slice(0,L-1)}…`}function ie(e){return{async ensure(t,n){await e.upsert({resource:t,firstSeenAt:n},{target:`resource`,set:{resource:t}})},async recordAttempt(t,n){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n},{target:`resource`,set:{lastAttemptAt:n}})},async recordSuccess(t,n,r){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged},{target:`resource`,set:{lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged}})},async recordFailure(t,n,r){let i=R(r);await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastError:i},{target:`resource`,set:{lastAttemptAt:n,lastError:i}})},async readAll(){return(await e.findMany({where:{resource:{in:[...a]}},limit:a.length})).map(e=>({resource:String(e.resource),firstSeenAt:e.firstSeenAt,lastAttemptAt:e.lastAttemptAt??null,lastSuccessAt:e.lastSuccessAt??null,lastError:e.lastError??null}))}}}const z=2e3,B=512*1024,V=l.string().max(32).regex(/^-?\d+(\.\d+)?$/,`expected a decimal literal, e.g. "180.00"`),H=l.object({et:l.string().max(500).nullable().optional(),en:l.string().max(500).nullable().optional()}),U=l.object({code:l.string().min(1).max(z),name:H,totalArea:V.nullable(),livingArea:V.nullable(),commonArea:V.nullable(),capacity:l.number().int().nullable(),monthlyRent:V.nullable(),discountedRent:V.nullable(),dailyRent:V.nullable(),placesOccupied:l.number().int().nullable()}),W=l.object({type:l.string().min(1).max(z),title:H,slug:l.string().min(1).max(z),htmlContentEt:l.string().max(B),htmlContentEn:l.string().max(B),order:l.number().int()}),G=l.object({success:l.literal(!0),roomTypes:l.array(U)}),K=l.object({success:l.literal(!0),documents:l.array(W)}),q=l.object({text:H,isActive:l.boolean(),order:l.number().int()}),J=l.object({success:l.literal(!0),receptionHours:H,notices:l.array(q).max(1e3)}),Y=8*1024*1024;async function ae(e,t,n){let r=e.body;if(!r)return``;let i=r.getReader(),a=new TextDecoder,o=0,s=``;try{for(;;){let{done:r,value:c}=await i.read();if(r)break;if(o+=c.byteLength,o>Y)throw await i.cancel(),new g(t,`shape`,`yhikas-admin sent more than ${Y} bytes for ${n}, above the ceiling — the read was aborted mid-stream rather than buffered and measured after`,{status:e.status});s+=a.decode(c,{stream:!0})}return s+a.decode()}finally{i.releaseLock()}}async function X(e){try{await e.body?.cancel()}catch{}}var oe=class{#e;#t;#n;#r;constructor(e){let t;try{t=new URL(e.baseUrl)}catch(t){throw TypeError(`yhikas-sync: baseUrl is not a valid URL: ${e.baseUrl}`,{cause:t})}if(t.protocol!==`http:`&&t.protocol!==`https:`)throw TypeError(`yhikas-sync: baseUrl must be http or https, got '${t.protocol}'. A file: or data: origin here would be a way to feed the sync a local snapshot.`);if(t.pathname!==`/`)throw TypeError(`yhikas-sync: baseUrl must be an origin with no path, got '${t.pathname}'. The upstream routes are absolute, so a base path would be silently discarded.`);if(e.apiKey.length===0)throw TypeError(`yhikas-sync: apiKey must not be empty`);this.#e=t,this.#t=e.apiKey,this.#n=e.timeoutMs,this.#r=e.fetchImpl??globalThis.fetch}async fetchRoomTypes(){return(await this.#i(o,e,G)).roomTypes}async fetchLegalDocuments(){return(await this.#i(s,i,K)).documents}async fetchSiteInfo(){return await this.#i(t,n,J)}async#i(e,t,n){let r=new URL(t,this.#e),i=AbortSignal.timeout(this.#n),a;try{a=await this.#r(r,{method:`GET`,headers:{authorization:`Bearer ${this.#t}`,accept:`application/json`},redirect:`error`,signal:i})}catch(n){let r=i.aborted;throw new g(e,r?`timeout`:`network`,r?`yhikas-admin did not answer ${t} within ${this.#n}ms`:`yhikas-admin was unreachable at ${t}`,{cause:n})}if(!a.ok)throw await X(a),new g(e,`http`,`yhikas-admin refused ${t} with HTTP ${a.status}`,{status:a.status});let o=Number(a.headers.get(`content-length`)??NaN);if(Number.isFinite(o)&&o>Y)throw await X(a),new g(e,`shape`,`yhikas-admin returned ${o} bytes for ${t}, above the ${Y}-byte ceiling`,{status:a.status});let s;try{s=await ae(a,e,t)}catch(n){if(n instanceof g)throw n;let r=i.aborted;throw new g(e,r?`timeout`:`shape`,r?`yhikas-admin did not finish sending ${t} within ${this.#n}ms`:`${t} returned a body that could not be read`,{status:a.status,cause:n})}let c;try{c=JSON.parse(s)}catch(n){throw new g(e,`shape`,`${t} returned a body that is not JSON`,{status:a.status,cause:n})}let l=n.safeParse(c);if(!l.success)throw new g(e,`shape`,`${t} returned a body that does not match the expected contract — ${l.error.issues.slice(0,5).map(e=>`${e.path.join(`.`)||`<root>`}: ${e.message}`).join(`; `)}`,{status:a.status});return l.data}},Z=class extends Error{stale;constructor(e,t){let n=e.map(se).join(`; `);super(`yhikas-admin sync is stale beyond the ${Q(t)} window. ${n}. The public site is serving content that old — this alert fires on ABSENCE of success, so there may be no failing job to look at.`),this.name=`YhikasSyncStaleError`,this.stale=e}};function se(e){let t=e.lastError?` — last error: ${e.lastError}`:``;return e.lastSuccessAt?`${e.resource}: last succeeded ${Q(e.ageMs)} ago${t}`:Number.isFinite(e.ageMs)?`${e.resource}: has NEVER succeeded (tracked for ${Q(e.ageMs)})${t}`:`${e.resource}: has NEVER synced — no tracking row exists at all${t}`}function Q(e){if(!Number.isFinite(e))return`an unknown time`;let t=e/36e5;return t<1?`${Math.round(e/6e4)}m`:t<48?`${Math.round(t)}h`:`${Math.round(t/24)}d`}function $(e,t,n){let r=new Map(e.map(e=>[e.resource,e]));return a.map(e=>{let i=r.get(e);if(!i)return{resource:e,lastSuccessAt:null,ageMs:1/0,stale:!0,lastError:null};let a=i.lastSuccessAt??i.firstSeenAt,o=t.getTime()-a.getTime();return{resource:e,lastSuccessAt:i.lastSuccessAt,ageMs:o,stale:o>n,lastError:i.lastError}})}function ce(e,t,n){let r=$(e,t,n),i=r.filter(e=>e.stale);if(i.length>0)throw new Z(i,n);return r}export{v as C,p as D,u as E,f as O,b as S,g as T,M as _,W as a,C as b,U as c,q as d,ie as f,x as g,re as h,oe as i,G as l,F as m,ce as n,K as o,R as p,$ as r,H as s,Z as t,J as u,j as v,_ as w,te as x,N as y};
2
- //# sourceMappingURL=watchdog-CcOXpzE3.mjs.map
1
+ import{C as e,D as t,E as n,O as r,S as i,T as a,j as o,k as s}from"./sync-state-table-Dq_wyKfo.mjs";import{createHash as c}from"node:crypto";import{z as l}from"zod";var u=class extends Error{counts;constructor(e,t){super(`${t.failed} of ${t.created+t.updated+t.retired+t.failed} ${e} writes failed. The run is NOT recorded as successful, so the staleness watchdog stays armed and the next run retries from scratch.`),this.name=`YhikasApplyPartialError`,this.counts=t}};function d(e){if(e instanceof Date)return Number.isNaN(e.getTime())?null:e;if(typeof e==`string`){let t=new Date(e);return Number.isNaN(t.getTime())?null:t}return null}async function f(e,t,n){return(await e.findMany({limit:n})).map(e=>({id:String(e.id),key:String(e[t]),status:String(e.status),sourceHash:typeof e.sourceHash==`string`?e.sourceHash:null,publishedAt:d(e.publishedAt)}))}async function p(e){let{resource:t,plan:n,client:r,secondaryLocale:i,logger:a}=e,o={created:0,updated:0,retired:0,unchanged:n.unchanged.length,failed:0};for(let e of n.create)try{let t=await r.create({...e.base,status:`draft`});await h(r,String(t.id),e,i),await m(r,String(t.id),e.hash,null),o.created+=1}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to create a synced row`)}for(let{local:e,row:s}of n.update)try{await r.update(e.id,{...s.base}),await h(r,e.id,s,i),await m(r,e.id,s.hash,e.publishedAt),o.updated+=1}catch(e){o.failed+=1,a.error({err:e,resource:t,key:s.key},`yhikas-sync: failed to update a synced row`)}for(let e of n.retire)try{await r.updateForLocale(e.id,{status:`draft`},i),await r.update(e.id,{status:`draft`}),o.retired+=1,a.warn({resource:t,key:e.key},`yhikas-sync: retiring a row that upstream no longer offers as publishable content`)}catch(n){o.failed+=1,a.error({err:n,resource:t,key:e.key},`yhikas-sync: failed to retire a synced row`)}if(o.failed>0)throw new u(t,o);return o}async function m(e,t,n,r){await e.update(t,{status:`published`,publishedAt:r??new Date,sourceHash:n})}async function h(e,t,n,r){if(n.secondary){await e.updateForLocale(t,{...n.secondary},r),await e.updateForLocale(t,{status:`published`},r);return}await e.updateForLocale(t,{status:`draft`},r),await e.deleteTranslation(t,r)}var g=class extends Error{resource;kind;status;constructor(e,t,n,r){super(n,r?.cause===void 0?void 0:{cause:r.cause}),this.name=`YhikasUpstreamError`,this.resource=e,this.kind=t,this.status=r?.status}},_=class extends Error{resource;reason;constructor(e,t,n){super(n),this.name=`YhikasSyncRefusedError`,this.resource=e,this.reason=t}};function v(e){return c(`sha256`).update(y(e)).digest(`hex`)}function y(e){return e===null?`null`:e===void 0?`undefined`:Array.isArray(e)?`[${e.map(y).join(`,`)}]`:typeof e==`object`?`{${Object.entries(e).filter(([,e])=>e!==void 0).sort(([e],[t])=>e<t?-1:+(e>t)).map(([e,t])=>`${JSON.stringify(e)}:${y(t)}`).join(`,`)}}`:JSON.stringify(e)}function b(e){let{resource:t,upstream:n,local:r,keyOf:i,hashOf:a,floor:o}=e;if(n.length>o.maxRows)throw new _(t,`oversized-snapshot`,`yhikas-admin returned ${n.length} ${t} rows, above the ${o.maxRows} ceiling. Refusing the run rather than processing a prefix — a truncated snapshot would read as ${n.length-o.maxRows} deletions.`);let s=new Map;for(let e of n){let n=i(e);if(s.has(n))throw new _(t,`duplicate-key`,`yhikas-admin returned two ${t} rows with the same business key '${n}'. That key is unique upstream, so the response is not a faithful snapshot.`);s.set(n,e)}let c=new Map;for(let e of r){if(c.has(e.key))throw new _(t,`duplicate-key`,`Local ${t} content holds two rows keyed '${e.key}'. Refusing to guess which is authoritative.`);c.set(e.key,e)}let l=[],u=[],d=[];for(let[e,t]of s){let n=c.get(e);if(!n){l.push(t);continue}n.sourceHash===a(t)&&n.status===`published`?d.push(n):u.push({local:n,row:t})}let f=r.filter(e=>e.status===`published`&&!s.has(e.key));return ee(t,n.length,r,f,o),{create:l,update:u,unchanged:d,retire:f}}function ee(e,t,n,r,i){if(r.length===0)return;if(t===0)throw new _(e,`empty-snapshot`,`yhikas-admin returned zero ${e} rows while ${r.length} are published locally. Refusing to retire content on the strength of an empty snapshot — that is how a partial upstream response becomes a wiped price sheet.`);let a=n.filter(e=>e.status===`published`).length;if(a<i.minRowsForFraction)return;let o=r.length/a;if(o>i.maxRetireFraction)throw new _(e,`retire-fraction`,`This run would retire ${r.length} of ${a} published ${e} rows (${Math.round(o*100)}%), above the ${Math.round(i.maxRetireFraction*100)}% floor. At this change rate a run proposing to retire most of the set is far likelier to be a bug than a business event.`)}function te(e,t){if(t.trim().length===0)throw TypeError(`yhikas-sync: defaultLocale must be a non-empty locale code`);return{findMany:t=>e.findMany(t),create:t=>e.create(t),update:(t,n)=>e.update(t,n),updateForLocale:(n,r,i)=>e.updateForLocale(n,r,i,{defaultLocale:t}),deleteTranslation:(t,n)=>e.deleteTranslation(t,n)}}const x=[`et`,`en`];function S(e){if(!x.includes(e))throw TypeError(`yhikas-sync: unsupported locale '${e}' — upstream carries only 'et' and 'en', and projecting one language's text under another locale would publish the wrong language while every write succeeded.`)}function C(e){return S(e),e===`et`?`en`:`et`}function w(e,t){return t===`et`?e.et:e.en}function T(e,t){return t===`et`?e.htmlContentEt:e.htmlContentEn}function E(e){return typeof e==`string`&&e.trim().length>0}function D(e){return E(e)?e.trim():``}const ne=/[\s:/\\<>"'`?#&%]/;function O(e){return!ne.test(e)&&!/[\u0000-\u001f\u007f]/.test(e)}function k(e,t,n,r){if(!(t===0||n>0))throw new _(e,`empty-content`,`yhikas-admin returned ${t} ${e} row(s) and ${n} of them are publishable — every one was skipped. One unusable row is an editorial gap and is skipped on its own; a whole snapshot of them is what a renamed column or a changed envelope looks like from here, and unknown keys are stripped by design so the schema cannot tell the difference. Refusing rather than reporting a successful run that published nothing: local content is unchanged and the staleness watchdog stays armed. Reasons: ${r.slice(0,A).map(e=>`${e.key}: ${e.reason}`).join(`; `)}`)}const A=3;function j(e,t){S(t.defaultLocale);let r=C(t.defaultLocale),i=[],a=[];for(let n of e){if(n.code.length>190){a.push({key:n.code.slice(0,80),reason:`upstream code exceeds the 190-character column`});continue}let e=w(n.name,t.defaultLocale);if(!E(e)){a.push({key:n.code,reason:`no '${t.defaultLocale}' name upstream — a room type with no name in the site's primary language has nothing publishable to render`});continue}let o=w(n.name,r),s={code:n.code,name:e,totalArea:n.totalArea,livingArea:n.livingArea,commonArea:n.commonArea,capacity:n.capacity,placesOccupied:n.placesOccupied,monthlyRent:n.monthlyRent,discountedMonthlyRent:n.discountedRent,dailyRent:n.dailyRent,currency:`EUR`,vatTreatment:`net`,hasEnglish:E(n.name.en)},c=E(o)?{name:o}:null;i.push({key:n.code,base:s,secondary:c,hash:P(s,c)})}return k(n,e.length,i.length,a),{projected:i,skipped:a}}function M(t,n,r){S(n.defaultLocale);let i=C(n.defaultLocale),a=[],o=[];for(let e of t){let t=w(e.title,n.defaultLocale),s=r(T(e,n.defaultLocale));if(!E(t)||!E(s)){o.push({key:e.type,reason:`no publishable '${n.defaultLocale}' title or body upstream`});continue}if(e.type.length>190||e.slug.length>190){o.push({key:e.type.slice(0,80),reason:`upstream type or slug exceeds the 190-character column — refusing this row rather than letting the write fail and hold the whole resource in failure`});continue}if(!O(e.slug)){o.push({key:e.type,reason:`upstream slug ${JSON.stringify(e.slug)} is not usable as an anchor fragment — it carries a scheme, a path separator, whitespace or markup. Refusing this row rather than the whole response: upstream derives the slug from an unvalidated free-text field, so one bad value must not take the resource offline`});continue}let c=w(e.title,i),l=r(T(e,i)),u=r(e.htmlContentEn),d={type:e.type,title:t,sourceSlug:e.slug,body:s,order:e.order,hasEnglish:E(e.title.en)&&E(u)},f=E(c)&&E(l)?{title:c,body:l}:null;a.push({key:e.type,base:d,secondary:f,hash:P(d,f)})}return k(e,t.length,a.length,o),{projected:a,skipped:o}}function N(e,n){S(n.defaultLocale);let r=C(n.defaultLocale),i=e.notices.filter(e=>e.isActive).sort((e,t)=>e.order-t.order),a=i.slice(0,25),o=[];i.length>a.length&&o.push({key:t,reason:`${i.length-a.length} active notice(s) beyond the 25 ticker ceiling were dropped — a marquee past a couple of dozen entries is not read, and an unbounded array from upstream would ride onto every page of the site`});let c=e=>a.map(t=>w(t.text,e)).filter(E),l=w(e.receptionHours,n.defaultLocale),u=c(n.defaultLocale);if(!E(l)&&u.length===0)throw new _(s,`empty-content`,`yhikas-admin returned a well-formed site-info response carrying no usable '${n.defaultLocale}' content: reception hours are empty, and of ${e.notices.length} notice(s) upstream, ${i.length} are active and ${u.length} have '${n.defaultLocale}' text. (Those three counts are reported separately on purpose — "no active notices" and "active notices with no text in this language" send an operator to different places.) That state is exactly what the endpoint returns when no row exists at all, and it is indistinguishable from a deliberate clearing — so it is refused rather than applied. Local content is unchanged and the staleness watchdog stays armed. Note this refusal is TERMINAL, not transient: it will repeat every run until upstream carries something. If the intent really was to clear everything, leave one of the two set.`);let d=w(e.receptionHours,r),f=c(r),p=w(e.receptionHours,`en`),m=c(`en`),h={key:t,receptionHours:D(l),notices:u,hasEnglish:E(p)||m.length>0},g=E(d)||f.length>0?{receptionHours:D(d),notices:f}:null;return{projected:[{key:t,base:h,secondary:g,hash:P(h,g)}],skipped:o}}function P(e,t){return v({base:e,secondary:t})}var F=class extends Error{summary;constructor(e){let t=e.outcomes.filter(e=>!e.ok);super(`yhikas-admin sync failed for ${t.map(e=>e.resource).join(`, `)}: `+t.map(e=>e.error).join(` | `)),this.name=`YhikasSyncRunError`,this.summary=e}};async function re(t){let r=t.now??(()=>new Date),i=C(t.defaultLocale),a=[];a.push(await I({resource:n,keyField:`code`,client:t.roomTypeClient,fetchAndProject:async()=>j(await t.upstream.fetchRoomTypes(),{defaultLocale:t.defaultLocale}),deps:t,secondaryLocale:i,now:r})),a.push(await I({resource:e,keyField:`type`,client:t.legalDocumentClient,fetchAndProject:async()=>M(await t.upstream.fetchLegalDocuments(),{defaultLocale:t.defaultLocale},t.sanitizeHtml),deps:t,secondaryLocale:i,now:r})),a.push(await I({resource:s,keyField:`key`,client:t.siteInfoClient,fetchAndProject:async()=>N(await t.upstream.fetchSiteInfo(),{defaultLocale:t.defaultLocale}),deps:t,secondaryLocale:i,now:r}));let o={outcomes:a,ok:a.every(e=>e.ok)};if(!o.ok)throw new F(o);return o}async function I(e){let{resource:t,keyField:n,client:r,fetchAndProject:i,deps:a,secondaryLocale:o,now:s}=e,{logger:c,state:l,floor:u}=a;try{await l.ensure(t,s()),await l.recordAttempt(t,s());let e=await i();for(let n of e.skipped.slice(0,20))c.warn({resource:t,key:n.key,reason:n.reason},`yhikas-sync: skipping an upstream row, or part of one, that cannot be published`);e.skipped.length>20&&c.warn({resource:t,suppressed:e.skipped.length-20,total:e.skipped.length},`yhikas-sync: further skipped rows not logged individually`);let a=await f(r,n,u.maxRows),d=await p({resource:t,plan:b({resource:t,upstream:e.projected,local:a,keyOf:e=>e.key,hashOf:e=>e.hash,floor:u}),client:r,secondaryLocale:o,logger:c});return await l.recordSuccess(t,s(),d),c.info({resource:t,...d,skipped:e.skipped.length},`yhikas-sync: resource synced`),{resource:t,ok:!0,counts:d,skipped:e.skipped.length,error:null}}catch(e){let n=e instanceof Error?e.message:String(e);c.error({err:e,resource:t},`yhikas-sync: resource sync failed — local content unchanged`);try{await l.recordFailure(t,s(),n)}catch(e){c.error({err:e,resource:t},`yhikas-sync: could not record the failure in yhikas_sync_state`)}return{resource:t,ok:!1,counts:null,skipped:0,error:n}}}const L=1024;function R(e){return e.length<=L?e:`${e.slice(0,L-1)}…`}function ie(e){return{async ensure(t,n){await e.upsert({resource:t,firstSeenAt:n},{target:`resource`,set:{resource:t}})},async recordAttempt(t,n){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n},{target:`resource`,set:{lastAttemptAt:n}})},async recordSuccess(t,n,r){await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged},{target:`resource`,set:{lastAttemptAt:n,lastSuccessAt:n,lastError:null,lastCreated:r.created,lastUpdated:r.updated,lastRetired:r.retired,lastUnchanged:r.unchanged}})},async recordFailure(t,n,r){let i=R(r);await e.upsert({resource:t,firstSeenAt:n,lastAttemptAt:n,lastError:i},{target:`resource`,set:{lastAttemptAt:n,lastError:i}})},async readAll(){return(await e.findMany({where:{resource:{in:[...o]}},limit:o.length})).map(e=>({resource:String(e.resource),firstSeenAt:e.firstSeenAt,lastAttemptAt:e.lastAttemptAt??null,lastSuccessAt:e.lastSuccessAt??null,lastError:e.lastError??null}))}}}const z=2e3,B=512*1024,V=l.string().max(32).regex(/^-?\d+(\.\d+)?$/,`expected a decimal literal, e.g. "180.00"`),H=l.object({et:l.string().max(500).nullable().optional(),en:l.string().max(500).nullable().optional()}),U=l.object({code:l.string().min(1).max(z),name:H,totalArea:V.nullable(),livingArea:V.nullable(),commonArea:V.nullable(),capacity:l.number().int().nullable(),monthlyRent:V.nullable(),discountedRent:V.nullable(),dailyRent:V.nullable(),placesOccupied:l.number().int().nullable()}),W=l.object({type:l.string().min(1).max(z),title:H,slug:l.string().min(1).max(z),htmlContentEt:l.string().max(B),htmlContentEn:l.string().max(B),order:l.number().int()}),G=l.object({success:l.literal(!0),roomTypes:l.array(U)}),K=l.object({success:l.literal(!0),documents:l.array(W)}),q=l.object({text:H,isActive:l.boolean(),order:l.number().int()}),J=l.object({success:l.literal(!0),receptionHours:H,notices:l.array(q).max(1e3)}),Y=8*1024*1024;async function ae(e,t,n){let r=e.body;if(!r)return``;let i=r.getReader(),a=new TextDecoder,o=0,s=``;try{for(;;){let{done:r,value:c}=await i.read();if(r)break;if(o+=c.byteLength,o>Y)throw await i.cancel(),new g(t,`shape`,`yhikas-admin sent more than ${Y} bytes for ${n}, above the ceiling — the read was aborted mid-stream rather than buffered and measured after`,{status:e.status});s+=a.decode(c,{stream:!0})}return s+a.decode()}finally{i.releaseLock()}}async function X(e){try{await e.body?.cancel()}catch{}}var oe=class{#e;#t;#n;#r;constructor(e){let t;try{t=new URL(e.baseUrl)}catch(t){throw TypeError(`yhikas-sync: baseUrl is not a valid URL: ${e.baseUrl}`,{cause:t})}if(t.protocol!==`http:`&&t.protocol!==`https:`)throw TypeError(`yhikas-sync: baseUrl must be http or https, got '${t.protocol}'. A file: or data: origin here would be a way to feed the sync a local snapshot.`);if(t.pathname!==`/`)throw TypeError(`yhikas-sync: baseUrl must be an origin with no path, got '${t.pathname}'. The upstream routes are absolute, so a base path would be silently discarded.`);if(e.apiKey.length===0)throw TypeError(`yhikas-sync: apiKey must not be empty`);this.#e=t,this.#t=e.apiKey,this.#n=e.timeoutMs,this.#r=e.fetchImpl??globalThis.fetch}async fetchRoomTypes(){return(await this.#i(n,a,G)).roomTypes}async fetchLegalDocuments(){return(await this.#i(e,i,K)).documents}async fetchSiteInfo(){return await this.#i(s,r,J)}async#i(e,t,n){let r=new URL(t,this.#e),i=AbortSignal.timeout(this.#n),a;try{a=await this.#r(r,{method:`GET`,headers:{authorization:`Bearer ${this.#t}`,accept:`application/json`},redirect:`error`,signal:i})}catch(n){let r=i.aborted;throw new g(e,r?`timeout`:`network`,r?`yhikas-admin did not answer ${t} within ${this.#n}ms`:`yhikas-admin was unreachable at ${t}`,{cause:n})}if(!a.ok)throw await X(a),new g(e,`http`,`yhikas-admin refused ${t} with HTTP ${a.status}`,{status:a.status});let o=Number(a.headers.get(`content-length`)??NaN);if(Number.isFinite(o)&&o>Y)throw await X(a),new g(e,`shape`,`yhikas-admin returned ${o} bytes for ${t}, above the ${Y}-byte ceiling`,{status:a.status});let s;try{s=await ae(a,e,t)}catch(n){if(n instanceof g)throw n;let r=i.aborted;throw new g(e,r?`timeout`:`shape`,r?`yhikas-admin did not finish sending ${t} within ${this.#n}ms`:`${t} returned a body that could not be read`,{status:a.status,cause:n})}let c;try{c=JSON.parse(s)}catch(n){throw new g(e,`shape`,`${t} returned a body that is not JSON`,{status:a.status,cause:n})}let l=n.safeParse(c);if(!l.success)throw new g(e,`shape`,`${t} returned a body that does not match the expected contract — ${l.error.issues.slice(0,5).map(e=>`${e.path.join(`.`)||`<root>`}: ${e.message}`).join(`; `)}`,{status:a.status});return l.data}},Z=class extends Error{stale;constructor(e,t){let n=e.map(se).join(`; `);super(`yhikas-admin sync is stale beyond the ${Q(t)} window. ${n}. The public site is serving content that old — this alert fires on ABSENCE of success, so there may be no failing job to look at.`),this.name=`YhikasSyncStaleError`,this.stale=e}};function se(e){let t=e.lastError?` — last error: ${e.lastError}`:``;return e.lastSuccessAt?`${e.resource}: last succeeded ${Q(e.ageMs)} ago${t}`:Number.isFinite(e.ageMs)?`${e.resource}: has NEVER succeeded (tracked for ${Q(e.ageMs)})${t}`:`${e.resource}: has NEVER synced — no tracking row exists at all${t}`}function Q(e){if(!Number.isFinite(e))return`an unknown time`;let t=e/36e5;return t<1?`${Math.round(e/6e4)}m`:t<48?`${Math.round(t)}h`:`${Math.round(t/24)}d`}function $(e,t,n){let r=new Map(e.map(e=>[e.resource,e]));return o.map(e=>{let i=r.get(e);if(!i)return{resource:e,lastSuccessAt:null,ageMs:1/0,stale:!0,lastError:null};let a=i.lastSuccessAt??i.firstSeenAt,o=t.getTime()-a.getTime();return{resource:e,lastSuccessAt:i.lastSuccessAt,ageMs:o,stale:o>n,lastError:i.lastError}})}function ce(e,t,n){let r=$(e,t,n),i=r.filter(e=>e.stale);if(i.length>0)throw new Z(i,n);return r}export{v as C,p as D,u as E,f as O,b as S,g as T,M as _,W as a,C as b,U as c,q as d,ie as f,x as g,re as h,oe as i,G as l,F as m,ce as n,K as o,R as p,$ as r,H as s,Z as t,J as u,j as v,_ as w,te as x,N as y};
2
+ //# sourceMappingURL=watchdog-C_ClM4PH.mjs.map