@murumets-ee/content 0.89.0 → 0.90.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,6 +1,6 @@
|
|
|
1
1
|
import { Entity } from "@murumets-ee/entity";
|
|
2
2
|
import { ToolkitApp } from "@murumets-ee/core";
|
|
3
|
-
import { CollectionFieldLookup, CollectionResult, CollectionSourceSpec, FindManyClientLike, LiveValues, TemplateNamespace } from "@murumets-ee/blocks/server";
|
|
3
|
+
import { CollectionFieldLookup, CollectionResult, CollectionSourceSpec, DocumentDetail, DocumentDetailPart, FindManyClientLike, LiveValues, TemplateNamespace } from "@murumets-ee/blocks/server";
|
|
4
4
|
|
|
5
5
|
//#region src/block-resolver.d.ts
|
|
6
6
|
/** Structural mirror of `content-api`'s `MediaProjection`. */
|
|
@@ -94,6 +94,13 @@ interface ResolvedBlockTree {
|
|
|
94
94
|
readonly live?: LiveValues;
|
|
95
95
|
/** The site's currency (block-library F012) — present whenever the site configures one. */
|
|
96
96
|
readonly currency?: string;
|
|
97
|
+
/**
|
|
98
|
+
* The row this tree is drawn for, projected through its entity's detail
|
|
99
|
+
* declaration (block-library PR16) — price, facts, a sanitised HTML body, a
|
|
100
|
+
* viewer. Absent unless the app declares a detail for the entity and the row
|
|
101
|
+
* has a value for at least one declared part.
|
|
102
|
+
*/
|
|
103
|
+
readonly detail?: DocumentDetail;
|
|
97
104
|
}
|
|
98
105
|
/**
|
|
99
106
|
* What resolving COLLECTION blocks needs from the app — built once per app by
|
|
@@ -171,6 +178,22 @@ interface BlockResolverDeps {
|
|
|
171
178
|
* number.
|
|
172
179
|
*/
|
|
173
180
|
currency?: string | undefined;
|
|
181
|
+
/**
|
|
182
|
+
* Project the row a tree is drawn for through its entity's detail
|
|
183
|
+
* declaration (block-library PR16). Omit and no tree carries a `detail` —
|
|
184
|
+
* deny-by-default, like `collections`. Query-free: the row is the one the
|
|
185
|
+
* caller is already delivering, under the caller's own access.
|
|
186
|
+
*
|
|
187
|
+
* `parts` are the detail parts the TREE's blocks draw; only those are
|
|
188
|
+
* projected, and a tree that draws none is never asked about.
|
|
189
|
+
*/
|
|
190
|
+
resolveDetail?: ((entity: string, row: Readonly<Record<string, unknown>>, locale: string, parts: ReadonlySet<DocumentDetailPart>) => DocumentDetail | undefined) | undefined;
|
|
191
|
+
/**
|
|
192
|
+
* Would projecting `parts` for `entity` sanitise an HTML body? That is the
|
|
193
|
+
* one detail part with a real CPU cost, so the resolver charges the
|
|
194
|
+
* response's budget for it. Omit and no projection is charged.
|
|
195
|
+
*/
|
|
196
|
+
detailNeedsBody?: ((entity: string, parts: ReadonlySet<DocumentDetailPart>, row: Readonly<Record<string, unknown>>) => boolean) | undefined;
|
|
174
197
|
}
|
|
175
198
|
/**
|
|
176
199
|
* Resolve one stored block tree into a {@link ResolvedBlockTree}.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":"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"}
|
package/dist/block-resolver.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as e}from"./resolve-context-Cvc1aNJe.mjs";import{r as t}from"./enumerate-CYHJKQz6.mjs";import{collectLinkValues as n,linkValueKey as r}from"@murumets-ee/blocks/links";import{canvasSiteFormat as i,collectBoundColumnsInTree as a,createCollectionReader as o,effectiveCollectionSources as s,hasConfiguredLiveSources as c,loadCanvasLiveValues as l,materializeServerTemplate as u,readSpotCrop as d,resolveLinks as f,resolveTreeCollections as p,sameSpotCrop as m,serverBlockStructureLookup as h,serverBoundColumnsInTree as g,serverCollectionFieldLookup as _,serverTemplateMaterializer as v,templateNamespace as y}from"@murumets-ee/blocks/server";const b=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function x(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function S(e){return Array.isArray(e)}const C=new Set(Object.keys({id:!0,url:!0,mimeType:!0,width:!0,height:!0,alt:!0,title:!0,variants:!0,sources:!0,avifSources:!0,focalPoint:!0,blurDataUrl:!0,crop:!0,spotSources:!0}));function w(e,t){if(!x(e))return null;let n=e.id;if(typeof n!=`string`||!b.test(n)||typeof e.url!=`string`||e.url===``)return null;let r=Object.keys(e).filter(e=>!C.has(e));return r.length>0?(t?.(r),null):!T(e.alt,`string`)||!T(e.title,`string`)||!T(e.mimeType,`string`)||!T(e.width,`number`)||!T(e.height,`number`)||!T(e.blurDataUrl,`string`)||e.variants!==void 0&&e.variants!==null&&!x(e.variants)||e.focalPoint!==void 0&&e.focalPoint!==null&&!x(e.focalPoint)||e.sources!==void 0&&e.sources!==null&&(!x(e.sources)||!Object.values(e.sources).every(e=>Array.isArray(e)))||e.avifSources!==void 0&&e.avifSources!==null&&(!x(e.avifSources)||!Object.values(e.avifSources).every(e=>Array.isArray(e)))||e.crop!==void 0&&e.crop!==null&&!x(e.crop)||e.spotSources!==void 0&&e.spotSources!==null&&!Array.isArray(e.spotSources)?null:n}function T(e,t){return e==null||typeof e===t}function E(e,t,n,r){if(n>256){r.truncated=!0;return}let i=e=>{for(let t of e)r.nearMissKeys.add(t)};if(S(e)){for(let a=0;a<e.length;a+=1){let o=w(e[a],i);o===null?E(e[a],t,n+1,r):t.push({container:e,key:a,id:o,crop:d(e[a])})}return}if(x(e))for(let[a,o]of Object.entries(e)){let s=w(o,i);s===null?E(o,t,n+1,r):t.push({container:e,key:a,id:s,crop:d(o)})}}function D(e){let t={id:e.id,url:e.url,variants:e.variants,focalPoint:e.focalPoint,sources:e.sources};return e.avifSources!==void 0&&Object.keys(e.avifSources).length>0&&(t.avifSources=e.avifSources),e.mimeType!==null&&(t.mimeType=e.mimeType),e.width!==null&&(t.width=e.width),e.height!==null&&(t.height=e.height),e.alt!==null&&(t.alt=e.alt),e.title!==null&&(t.title=e.title),e.blurDataUrl!==null&&(t.blurDataUrl=e.blurDataUrl),t}function O(e,t){if(!(t===void 0||e.spotSources===void 0))return e.spotSources.find(e=>m(e.crop,t)&&e.sources.length>0)}function k(e,t){let n=D(e),r=O(e,t),i=r?.sources[r.sources.length-1];if(r!==void 0&&i!==void 0){n.url=i.url,n.width=i.width,n.height=i.height,n.focalPoint={x:.5,y:.5},n.sources={natural:r.sources};let e=r.avifSources;e!==void 0&&e.length===r.sources.length&&e.every((e,t)=>e.width===r.sources[t]?.width&&e.height===r.sources[t]?.height)?n.avifSources={natural:e}:delete n.avifSources}return n.crop={x:t.x,y:t.y,w:t.w,h:t.h},n}function A(e,t){let n=t===null?null:e.crop===void 0?D(t):k(t,e.crop);if(Array.isArray(e.container)){typeof e.key==`number`&&(e.container[e.key]=n);return}typeof e.key==`string`&&(e.container[e.key]=n)}async function j(e,t,i){let a=e,o=[];if(t.template!==void 0){let e=t.boundValues?.id;if(typeof e==`string`&&e!==``){let n=(i.materializeTemplate??u)(t.template,y(t.entity,e));a=n.blocks,o=n.boundColumns}else a=[],i.logger?.warn({entity:t.entity,field:t.field},`content: a templated document arrived without its row id, so its template is served EMPTY — its block ids could not be namespaced to it (editor F124)`)}else if(t.boundValues!==void 0&&S(e))try{o=(i.boundColumnsOf??g)(e)}catch(e){i.logger?.warn({err:e,entity:t.entity,field:t.field},`content: could not enumerate the bound columns of a block tree, so its bound-field links render plain`)}let s=t.boundValues??{},c=o.filter(e=>Object.hasOwn(s,e)).map(e=>s[e]);if(!S(a))throw i.logger?.warn({entity:t.entity,field:t.field},`content: stored block tree is not an array — nothing to resolve, so this field is served EMPTY rather than as stored (the caller retries this throw with an exhausted budget, and a non-array fails that identically)`),TypeError(`content: block tree \`${t.entity}.${t.field}\` is not an array; nothing to resolve.`);let l=[],d={truncated:!1,nearMissKeys:new Set};E(a,l,0,d),d.truncated&&i.logger?.warn({entity:t.entity,field:t.field,cap:256},`content: block tree is nested deeper than the media walk descends — any media ref below the bound keeps the url recorded when an editor picked it, which may be signed`),d.nearMissKeys.size>0&&i.logger?.debug({entity:t.entity,field:t.field,keys:[...d.nearMissKeys].sort()},`content: a value looked like a media ref but carried keys outside the projection, so it was left alone. If these are media refs, they keep their stored urls`);let p=[...n(a,64),...n(c,64)],m=[],h=[];for(let e of p)e.kind===`media`&&(b.test(e.id)?m.push(e.id):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let _=P([...l.map(e=>e.id),...m]),v=[];for(let e of p)e.kind===`entity`&&(b.test(e.id)?v.push(F(e.entity,e.id)):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let x=[...h];!i.resolveMedia&&_.length>0&&i.logger?.debug({entity:t.entity,field:t.field,count:_.length},`content: no media resolver wired for block trees — every media ref resolves to null`);let C=i.resolveMedia?t.budget.claim(`block`,_.length):0,w=_.slice(0,C),T=i.resolveMedia?new Set(_.slice(C)):new Set;for(let e of _.slice(C))x.push({kind:`media`,ref:e,reason:T.has(e)?`budget`:`unresolvable`});let D=P(v),O=t.budget.claim(`block`,D.length),k=D.slice(0,O),j=new Set(D.slice(O)),z=await L(w,t.locale,i,t.entity);for(let e of l)A(e,z.get(e.id)??null);for(let e of w)z.has(e)||x.push({kind:`media`,ref:e,reason:`unresolvable`});let B=await R(k,t.locale,i,t.entity),V=await f(p,{renderContext:`request`,locale:t.locale,resolveEntityHref:({entity:e,id:t})=>B.get(F(e,t))??null,resolveMediaHref:e=>z.get(e)?.url??null}),H={};for(let[e,t]of V)H[e]=t.href;for(let e of p){if(e.kind!==`entity`&&e.kind!==`media`)continue;let t=r(e);H[t]!==null&&H[t]!==void 0||x.push({kind:`link`,ref:t,reason:N(e,j,T)})}let U=i.resolveLiveValues,W=U===void 0?0:t.budget.claim(`block`,1),[{results:G,cardMedia:K},q]=await Promise.all([M(a,t,i),W>0&&U!==void 0?(async()=>U(a,t.locale))().catch(e=>{i.logger?.warn({err:e},`blocks: resolveLiveValues rejected; this tree draws no live values`)}):Promise.resolve(void 0)]),J=new Map;for(let e of z.keys())J.set(F(`media`,e),{entity:`media`,id:e});for(let e of K)J.set(F(`media`,e),{entity:`media`,id:e});for(let e of G.values())if(e.entity!==null)for(let t of e.rows)J.set(F(e.entity,t.id),{entity:e.entity,id:t.id});for(let e of B.keys()){if(J.has(e))continue;let t=e.indexOf(` `);if(t<=0)continue;let n=e.slice(0,t),r=e.slice(t+1);r!==``&&J.set(e,{entity:n,id:r})}return{resolved:1,blocks:a,links:H,...x.length>0&&{unresolved:I(x)},...J.size>0&&{refs:[...J.values()]},...G.size>0&&{collections:Object.fromEntries(G)},...q!==void 0&&Object.keys(q.sources).length>0&&{live:{sources:q.sources,values:q.values}},...i.currency!==void 0&&{currency:i.currency}}}async function M(e,t,n){let r=n.collections,i=t.canReadEntity,a=t.claimCollection;if(r===void 0||i===void 0||a===void 0)return{results:new Map,cardMedia:new Set};let o=n.resolveMedia,s=new Set;return{results:await p(e,r.sourceFieldOf,{sources:r.sources,getEntity:r.getEntity,clientFor:r.clientFor,canRead:i,resolveImages:o===void 0?void 0:async e=>{let n=[...await o(e,{locale:t.locale})].filter(([,e])=>e.mimeType?.startsWith(`image/`)===!0);for(let[e]of n)s.add(e);return new Map(n.map(([e,t])=>[e,{url:t.url,alt:t.alt,width:t.width,height:t.height,focalPoint:t.focalPoint,sources:t.sources,...t.avifSources!==void 0&&Object.keys(t.avifSources).length>0&&{avifSources:t.avifSources},blurDataUrl:t.blurDataUrl}]))},resolveHrefs:(e,r)=>n.resolvePublishedPaths(e,r,t.locale),locale:t.locale,renderContext:`request`,claim:()=>a()&&t.budget.claim(`block`,1)>=1,logger:n.logger}),cardMedia:s}}function N(e,t,n){return e.kind===`entity`&&t.has(F(e.entity,e.id))||e.kind===`media`&&n.has(e.id)?`budget`:`unresolvable`}function P(e){return[...new Set(e)]}function F(e,t){return[e,t].join(` `)}function I(e){let t=new Set,n=[];for(let r of e){let e=`${r.kind} ${r.ref}`;t.has(e)||(t.add(e),n.push(r))}return n}async function L(e,t,n,r){if(e.length===0||!n.resolveMedia)return new Map;try{return await n.resolveMedia(e,{locale:t,spots:!0})}catch(e){return n.logger?.warn({entity:r,err:e},"content: media resolver threw while resolving a block tree — its media refs resolve to null (if this is a ForbiddenError, the requesting role is missing `media:view`)"),new Map}}async function R(e,t,n,r){let i=new Map;if(e.length===0)return i;let a=new Map;for(let t of e){let e=t.indexOf(` `),n=t.slice(0,e),r=t.slice(e+1),i=a.get(n);i?i.push(r):a.set(n,[r])}for(let[e,o]of a)try{let r=await n.resolvePublishedPaths(e,o,t);for(let[t,n]of r)i.set(F(e,t),n)}catch(t){n.logger?.warn({entity:r,entityType:e,err:t},`content: path resolution threw for a block-tree link target — its links render as plain text`)}return i}function z(n,r){let u=null,d=n=>{if(u?.app===n)return u.deps;let d=e(n),{sources:f}=s(n),p=f.length===0?void 0:{sources:f,sourceFieldOf:_(),getEntity:e=>n.entities.get(e),clientFor:e=>o(e,n)},m=v(),g=h(),y={...p!==void 0&&{collections:p},boundColumnsOf:e=>a(e,g),materializeTemplate:(e,t)=>m(e,t),resolveMedia:r.resolveMedia,resolvePublishedPaths:async(e,n,r)=>{let i=await d.filterPublished(e,n,r);return i.size===0?new Map:t(e,[...i],r,d)},logger:n.logger,...c()&&{resolveLiveValues:(e,t)=>l(e,{locale:t,logger:n.logger})},currency:i(r.defaultLocale).currency};return u={app:n,deps:y},y};return async(e,t)=>{let i=await n();return j(e,{...t,locale:t.locale??r.defaultLocale},d(i))}}export{z as createBlockResolver,j as resolveBlockTree};
|
|
1
|
+
import{r as e}from"./resolve-context-Cvc1aNJe.mjs";import{r as t}from"./enumerate-CYHJKQz6.mjs";import{collectLinkValues as n,linkValueKey as r}from"@murumets-ee/blocks/links";import{canvasSiteFormat as i,collectBoundColumnsInTree as a,createCollectionReader as o,detailNeedsBody as s,detailPartsInTree as c,effectiveCollectionSources as l,effectiveDetailSpecs as u,hasConfiguredLiveSources as d,loadCanvasLiveValues as f,materializeServerTemplate as p,readSpotCrop as m,resolveDocumentDetail as h,resolveLinks as g,resolveTreeCollections as _,sameSpotCrop as v,serverBlockStructureLookup as y,serverBoundColumnsInTree as b,serverCollectionFieldLookup as x,serverTemplateMaterializer as S,templateNamespace as C}from"@murumets-ee/blocks/server";const w=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function T(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function E(e){return Array.isArray(e)}const D=new Set(Object.keys({id:!0,url:!0,mimeType:!0,width:!0,height:!0,alt:!0,title:!0,variants:!0,sources:!0,avifSources:!0,focalPoint:!0,blurDataUrl:!0,crop:!0,spotSources:!0}));function O(e,t){if(!T(e))return null;let n=e.id;if(typeof n!=`string`||!w.test(n)||typeof e.url!=`string`||e.url===``)return null;let r=Object.keys(e).filter(e=>!D.has(e));return r.length>0?(t?.(r),null):!k(e.alt,`string`)||!k(e.title,`string`)||!k(e.mimeType,`string`)||!k(e.width,`number`)||!k(e.height,`number`)||!k(e.blurDataUrl,`string`)||e.variants!==void 0&&e.variants!==null&&!T(e.variants)||e.focalPoint!==void 0&&e.focalPoint!==null&&!T(e.focalPoint)||e.sources!==void 0&&e.sources!==null&&(!T(e.sources)||!Object.values(e.sources).every(e=>Array.isArray(e)))||e.avifSources!==void 0&&e.avifSources!==null&&(!T(e.avifSources)||!Object.values(e.avifSources).every(e=>Array.isArray(e)))||e.crop!==void 0&&e.crop!==null&&!T(e.crop)||e.spotSources!==void 0&&e.spotSources!==null&&!Array.isArray(e.spotSources)?null:n}function k(e,t){return e==null||typeof e===t}function A(e,t,n,r){if(n>256){r.truncated=!0;return}let i=e=>{for(let t of e)r.nearMissKeys.add(t)};if(E(e)){for(let a=0;a<e.length;a+=1){let o=O(e[a],i);o===null?A(e[a],t,n+1,r):t.push({container:e,key:a,id:o,crop:m(e[a])})}return}if(T(e))for(let[a,o]of Object.entries(e)){let s=O(o,i);s===null?A(o,t,n+1,r):t.push({container:e,key:a,id:s,crop:m(o)})}}function j(e){let t={id:e.id,url:e.url,variants:e.variants,focalPoint:e.focalPoint,sources:e.sources};return e.avifSources!==void 0&&Object.keys(e.avifSources).length>0&&(t.avifSources=e.avifSources),e.mimeType!==null&&(t.mimeType=e.mimeType),e.width!==null&&(t.width=e.width),e.height!==null&&(t.height=e.height),e.alt!==null&&(t.alt=e.alt),e.title!==null&&(t.title=e.title),e.blurDataUrl!==null&&(t.blurDataUrl=e.blurDataUrl),t}function M(e,t){if(!(t===void 0||e.spotSources===void 0))return e.spotSources.find(e=>v(e.crop,t)&&e.sources.length>0)}function N(e,t){let n=j(e),r=M(e,t),i=r?.sources[r.sources.length-1];if(r!==void 0&&i!==void 0){n.url=i.url,n.width=i.width,n.height=i.height,n.focalPoint={x:.5,y:.5},n.sources={natural:r.sources};let e=r.avifSources;e!==void 0&&e.length===r.sources.length&&e.every((e,t)=>e.width===r.sources[t]?.width&&e.height===r.sources[t]?.height)?n.avifSources={natural:e}:delete n.avifSources}return n.crop={x:t.x,y:t.y,w:t.w,h:t.h},n}function P(e,t){let n=t===null?null:e.crop===void 0?j(t):N(t,e.crop);if(Array.isArray(e.container)){typeof e.key==`number`&&(e.container[e.key]=n);return}typeof e.key==`string`&&(e.container[e.key]=n)}async function F(e,t,i){let a=e,o=[];if(t.template!==void 0){let e=t.boundValues?.id;if(typeof e==`string`&&e!==``){let n=(i.materializeTemplate??p)(t.template,C(t.entity,e));a=n.blocks,o=n.boundColumns}else a=[],i.logger?.warn({entity:t.entity,field:t.field},`content: a templated document arrived without its row id, so its template is served EMPTY — its block ids could not be namespaced to it (editor F124)`)}else if(t.boundValues!==void 0&&E(e))try{o=(i.boundColumnsOf??b)(e)}catch(e){i.logger?.warn({err:e,entity:t.entity,field:t.field},`content: could not enumerate the bound columns of a block tree, so its bound-field links render plain`)}let s=t.boundValues??{},l=o.filter(e=>Object.hasOwn(s,e)).map(e=>s[e]);if(!E(a))throw i.logger?.warn({entity:t.entity,field:t.field},`content: stored block tree is not an array — nothing to resolve, so this field is served EMPTY rather than as stored (the caller retries this throw with an exhausted budget, and a non-array fails that identically)`),TypeError(`content: block tree \`${t.entity}.${t.field}\` is not an array; nothing to resolve.`);let u=[],d={truncated:!1,nearMissKeys:new Set};A(a,u,0,d),d.truncated&&i.logger?.warn({entity:t.entity,field:t.field,cap:256},`content: block tree is nested deeper than the media walk descends — any media ref below the bound keeps the url recorded when an editor picked it, which may be signed`),d.nearMissKeys.size>0&&i.logger?.debug({entity:t.entity,field:t.field,keys:[...d.nearMissKeys].sort()},`content: a value looked like a media ref but carried keys outside the projection, so it was left alone. If these are media refs, they keep their stored urls`);let f=[...n(a,64),...n(l,64)],m=[],h=[];for(let e of f)e.kind===`media`&&(w.test(e.id)?m.push(e.id):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let _=R([...u.map(e=>e.id),...m]),v=[];for(let e of f)e.kind===`entity`&&(w.test(e.id)?v.push(z(e.entity,e.id)):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let y=[...h];!i.resolveMedia&&_.length>0&&i.logger?.debug({entity:t.entity,field:t.field,count:_.length},`content: no media resolver wired for block trees — every media ref resolves to null`);let x=i.resolveMedia?t.budget.claim(`block`,_.length):0,S=_.slice(0,x),T=i.resolveMedia?new Set(_.slice(x)):new Set;for(let e of _.slice(x))y.push({kind:`media`,ref:e,reason:T.has(e)?`budget`:`unresolvable`});let D=R(v),O=t.budget.claim(`block`,D.length),k=D.slice(0,O),j=new Set(D.slice(O)),M=await V(S,t.locale,i,t.entity);for(let e of u)P(e,M.get(e.id)??null);for(let e of S)M.has(e)||y.push({kind:`media`,ref:e,reason:`unresolvable`});let N=await H(k,t.locale,i,t.entity),F=await g(f,{renderContext:`request`,locale:t.locale,resolveEntityHref:({entity:e,id:t})=>N.get(z(e,t))??null,resolveMediaHref:e=>M.get(e)?.url??null}),U={};for(let[e,t]of F)U[e]=t.href;for(let e of f){if(e.kind!==`entity`&&e.kind!==`media`)continue;let t=r(e);U[t]!==null&&U[t]!==void 0||y.push({kind:`link`,ref:t,reason:L(e,j,T)})}let W=i.resolveLiveValues,G=W===void 0?0:t.budget.claim(`block`,1),[{results:K,cardMedia:q},J]=await Promise.all([I(a,t,i),G>0&&W!==void 0?(async()=>W(a,t.locale))().catch(e=>{i.logger?.warn({err:e},`blocks: resolveLiveValues rejected; this tree draws no live values`)}):Promise.resolve(void 0)]),Y;if(i.resolveDetail!==void 0&&t.boundValues!==void 0)try{let e=c(E(a)?a:[]);if(e.has(`body`)&&i.detailNeedsBody?.(t.entity,e,t.boundValues)===!0&&t.budget.claim(`block`,1)<1){let n=new Set(e);n.delete(`body`),e=n,i.logger?.warn({entity:t.entity,field:t.field},`blocks: the response budget is spent; this tree is delivered without its HTML body`)}e.size>0&&(Y=i.resolveDetail(t.entity,t.boundValues,t.locale,e))}catch(e){i.logger?.warn({err:e,entity:t.entity},`blocks: resolveDetail threw; this tree carries no detail`)}let X=new Map;for(let e of M.keys())X.set(z(`media`,e),{entity:`media`,id:e});for(let e of q)X.set(z(`media`,e),{entity:`media`,id:e});for(let e of K.values())if(e.entity!==null)for(let t of e.rows)X.set(z(e.entity,t.id),{entity:e.entity,id:t.id});for(let e of N.keys()){if(X.has(e))continue;let t=e.indexOf(` `);if(t<=0)continue;let n=e.slice(0,t),r=e.slice(t+1);r!==``&&X.set(e,{entity:n,id:r})}return{resolved:1,blocks:a,links:U,...y.length>0&&{unresolved:B(y)},...X.size>0&&{refs:[...X.values()]},...K.size>0&&{collections:Object.fromEntries(K)},...J!==void 0&&Object.keys(J.sources).length>0&&{live:{sources:J.sources,values:J.values}},...i.currency!==void 0&&{currency:i.currency},...Y!==void 0&&{detail:Y}}}async function I(e,t,n){let r=n.collections,i=t.canReadEntity,a=t.claimCollection;if(r===void 0||i===void 0||a===void 0)return{results:new Map,cardMedia:new Set};let o=n.resolveMedia,s=new Set;return{results:await _(e,r.sourceFieldOf,{sources:r.sources,getEntity:r.getEntity,clientFor:r.clientFor,canRead:i,resolveImages:o===void 0?void 0:async e=>{let n=[...await o(e,{locale:t.locale})].filter(([,e])=>e.mimeType?.startsWith(`image/`)===!0);for(let[e]of n)s.add(e);return new Map(n.map(([e,t])=>[e,{url:t.url,alt:t.alt,width:t.width,height:t.height,focalPoint:t.focalPoint,sources:t.sources,...t.avifSources!==void 0&&Object.keys(t.avifSources).length>0&&{avifSources:t.avifSources},blurDataUrl:t.blurDataUrl}]))},resolveHrefs:(e,r)=>n.resolvePublishedPaths(e,r,t.locale),locale:t.locale,renderContext:`request`,claim:()=>a()&&t.budget.claim(`block`,1)>=1,logger:n.logger}),cardMedia:s}}function L(e,t,n){return e.kind===`entity`&&t.has(z(e.entity,e.id))||e.kind===`media`&&n.has(e.id)?`budget`:`unresolvable`}function R(e){return[...new Set(e)]}function z(e,t){return[e,t].join(` `)}function B(e){let t=new Set,n=[];for(let r of e){let e=`${r.kind} ${r.ref}`;t.has(e)||(t.add(e),n.push(r))}return n}async function V(e,t,n,r){if(e.length===0||!n.resolveMedia)return new Map;try{return await n.resolveMedia(e,{locale:t,spots:!0})}catch(e){return n.logger?.warn({entity:r,err:e},"content: media resolver threw while resolving a block tree — its media refs resolve to null (if this is a ForbiddenError, the requesting role is missing `media:view`)"),new Map}}async function H(e,t,n,r){let i=new Map;if(e.length===0)return i;let a=new Map;for(let t of e){let e=t.indexOf(` `),n=t.slice(0,e),r=t.slice(e+1),i=a.get(n);i?i.push(r):a.set(n,[r])}for(let[e,o]of a)try{let r=await n.resolvePublishedPaths(e,o,t);for(let[t,n]of r)i.set(z(e,t),n)}catch(t){n.logger?.warn({entity:r,entityType:e,err:t},`content: path resolution threw for a block-tree link target — its links render as plain text`)}return i}function U(n,r){let c=null,p=n=>{if(c?.app===n)return c.deps;let p=e(n),{sources:m}=l(n),g=m.length===0?void 0:{sources:m,sourceFieldOf:x(),getEntity:e=>n.entities.get(e),clientFor:e=>o(e,n)},_=S(),v=y(),b=u(n).specs,C={...g!==void 0&&{collections:g},boundColumnsOf:e=>a(e,v),materializeTemplate:(e,t)=>_(e,t),resolveMedia:r.resolveMedia,resolvePublishedPaths:async(e,n,r)=>{let i=await p.filterPublished(e,n,r);return i.size===0?new Map:t(e,[...i],r,p)},logger:n.logger,...d()&&{resolveLiveValues:(e,t)=>f(e,{locale:t,logger:n.logger})},currency:i(r.defaultLocale).currency,...b.size>0&&{resolveDetail:(e,t,n,r)=>{let i=b.get(e);return i===void 0?void 0:h(i,t,n,r)},detailNeedsBody:(e,t,n)=>{let r=b.get(e);return r!==void 0&&s(r,t,n)}}};return c={app:n,deps:C},C};return async(e,t)=>{let i=await n();return F(e,{...t,locale:t.locale??r.defaultLocale},p(i))}}export{U as createBlockResolver,F as resolveBlockTree};
|
|
2
2
|
//# sourceMappingURL=block-resolver.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionFieldLookup,\n type CollectionResult,\n type CollectionSourceSpec,\n canvasSiteFormat,\n collectBoundColumnsInTree,\n createCollectionReader,\n effectiveCollectionSources,\n type FindManyClientLike,\n hasConfiguredLiveSources,\n type LiveValues,\n loadCanvasLiveValues,\n materializeServerTemplate,\n readSpotCrop,\n resolveLinks,\n resolveTreeCollections,\n type SpotCrop,\n sameSpotCrop,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n /**\n * The files cut for each spot crop the photograph has files for (images\n * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and\n * the photograph records some. Never embedded in a tree: {@link writeSlot}\n * picks the one list a placement's own `crop` names and drops the rest.\n */\n spotSources?: BlockSpotSources[]\n}\n\n/** One spot crop's files, as the media resolver publishes them. */\nexport interface BlockSpotSources {\n crop: { x: number; y: number; w: number; h: number }\n sources: Array<{ url: string; width: number; height: number }>\n avifSources?: Array<{ url: string; width: number; height: number }>\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: {\n locale?: string | undefined\n /**\n * Also publish the files cut for spot crops (images PR16). Asked by the\n * BLOCK walk only — a top-level media field has no placement crop, so its\n * projection stays exactly what it was.\n */\n spots?: boolean | undefined\n },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n /** What this tree's live-bound Price fields resolved to (block-library F012). */\n readonly live?: LiveValues\n /** The site's currency (block-library F012) — present whenever the site configures one. */\n readonly currency?: string\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n /** Name AND field restriction (kulli-editor F019) — `serverCollectionFieldLookup()`. */\n readonly sourceFieldOf: CollectionFieldLookup\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n /**\n * Resolve this tree's live-bound Price values (block-library PR03, F012).\n * Omit and a live-bound Price draws nothing — same deny-by-default rule as\n * `collections`. Defaults to `loadCanvasLiveValues`, the SAME resolver the\n * canvas's own entry points call, so a price cannot bind through a\n * different path (or a different source set) than the one the author saw\n * while editing.\n */\n resolveLiveValues?: ((roots: readonly unknown[], locale: string) => Promise<LiveValues>) | undefined\n /**\n * The site's currency (ISO 4217), from `blocksPlugin({ currency })`\n * (block-library F012). Independent of `resolveLiveValues`: a plain,\n * typed-in Price needs it too, to show a currency symbol rather than a bare\n * number.\n */\n currency?: string | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection | 'crop', true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n // A STORED key (images PR16, D002): the one crop a placement carries — author\n // data about the spot, validated at every write. Listed in the same commit\n // that let the ref carry it: an unrecognised key makes the detector walk\n // past the ref, and its stored url would cross unresolved (CLAUDE.md,\n // Images fact 2). Kept on the embedded value, so a second pass still sees it.\n crop: true,\n // Projection-only, and never embedded — `writeSlot` consumes it. Listed\n // because the map is tied to the projection's keys by type.\n spotSources: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n // A spot crop is an object. A project prop holding something else under\n // `crop` is left alone rather than claimed. An object that is not a VALID\n // crop is still a media ref — the write gate refuses those, and a stored one\n // is drawn around the focal point (`readSpotCrop` is lenient) rather than\n // being left unresolved with its stored url.\n if (value.crop !== undefined && value.crop !== null && !isRecord(value.crop)) return null\n if (value.spotSources !== undefined && value.spotSources !== null) {\n if (!Array.isArray(value.spotSources)) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n /** The placement's own crop (images PR16), read when the slot was found. */\n readonly crop?: SpotCrop | undefined\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null)\n out.push({ container: node, key: index, id, crop: readSpotCrop(node[index]) })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id, crop: readSpotCrop(value) })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\n/**\n * The files cut for THIS placement's crop, or `undefined` when the worker has\n * not cut them yet (or the placement has no crop). Matched on the four-decimal\n * grid (`sameSpotCrop`).\n */\nfunction spotSourcesFor(\n projection: BlockMediaProjection,\n crop: SpotCrop | undefined,\n): BlockSpotSources | undefined {\n if (crop === undefined || projection.spotSources === undefined) return undefined\n return projection.spotSources.find(\n (spot) => sameSpotCrop(spot.crop, crop) && spot.sources.length > 0,\n )\n}\n\n/**\n * The embedded value for a placement with a spot crop (images PR16, D002).\n *\n * - **Its files exist** — the placement's value becomes THOSE files: `sources`\n * holds the crop's list under `natural` (the crop IS this placement's\n * picture, at the crop's own ratio), `url`/`width`/`height` are the largest\n * file's, and the focal point is the centre (the file is already the crop).\n * So a render selects from the crop's files alone — the original is never\n * appended above them, because it is no longer larger than the value's own\n * width — and CSS has nothing left to crop. `avifSources` follows only when\n * the crop's AVIF files are exactly its WebP list, re-encoded.\n * - **They do not exist yet** — the projection as any placement gets it: the\n * focal-point files, never the original (D002). The crop is kept on the\n * value either way, so a second pass sees the same placement.\n *\n * A placement with NO crop never reaches this function, so its embedded value\n * — and the markup a render makes of it — is byte-identical to before.\n */\nfunction embedCropped(projection: BlockMediaProjection, crop: SpotCrop): Record<string, unknown> {\n const out = toEmbeddedProjection(projection)\n const spot = spotSourcesFor(projection, crop)\n const largest = spot?.sources[spot.sources.length - 1]\n if (spot !== undefined && largest !== undefined) {\n out.url = largest.url\n out.width = largest.width\n out.height = largest.height\n out.focalPoint = { x: 0.5, y: 0.5 }\n out.sources = { natural: spot.sources }\n const avif = spot.avifSources\n const avifMatches =\n avif !== undefined &&\n avif.length === spot.sources.length &&\n avif.every(\n (file, i) =>\n file.width === spot.sources[i]?.width && file.height === spot.sources[i]?.height,\n )\n if (avifMatches) out.avifSources = { natural: avif }\n else delete out.avifSources\n }\n out.crop = { x: crop.x, y: crop.y, w: crop.w, h: crop.h }\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written =\n value === null\n ? null\n : slot.crop === undefined\n ? toEmbeddedProjection(value)\n : embedCropped(value, slot.crop)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n // block-library F012 — the delivered-site half of PR03's live-source\n // contract. Deny-by-default like collections: no `resolveLiveValues`\n // wired, no `live` sidecar, and a bound Price draws nothing (the same\n // thing the canvas draws for a site with no sources at all).\n //\n // 🔴 CLAIMED against the SAME shared budget every other resolution kind in\n // this function draws from, same as `collections`' own `claimCollection`.\n // Without it, a list response resolving N rows each binding a live Price\n // would call the site's own `LiveSourceSpec.resolve` — an ARBITRARY,\n // site-registered callback, explicitly documented as reaching outside this\n // process (`core/live-sources.ts`: \"never elevate… it runs inside the\n // request's own context\") — once per row with no ceiling at all: exactly\n // the amplification CLAUDE.md's no-unbounded-fan-out section forbids,\n // reachable the moment any site registers a live source. One unit, because\n // a tree either asks its source(s) once or not at all — unlike media/links,\n // there is no per-item count to charge, and a 1-unit claim is what the\n // collection pass already charges for the same \"one call, unknown size\"\n // shape.\n const resolveLive = deps.resolveLiveValues\n const liveGranted = resolveLive !== undefined ? opts.budget.claim('block', 1) : 0\n\n // Run alongside collections rather than after: neither's result feeds the\n // other, and `resolveLiveValues` can take up to several seconds against a\n // slow or down upstream (`LIVE_DOCUMENT_TIMEOUT_MS`), which otherwise\n // stacked on top of collection resolution's own latency for no reason.\n const [{ results: collections, cardMedia }, live] = await Promise.all([\n resolveCollectionsIn(tree, opts, deps),\n liveGranted > 0 && resolveLive !== undefined\n ? // `(async () => …)()` rather than calling `resolveLive` directly: its\n // type (`BlockResolverDeps['resolveLiveValues']`) only requires a\n // `Promise<LiveValues>` RETURN, not an `async` implementation, so a\n // misbehaving caller-supplied resolver could throw SYNCHRONOUSLY\n // before ever producing a promise for `.catch()` to attach to — which\n // would reject `Promise.all` (and this whole function) before the\n // `.catch()` below is ever reached. Wrapping in an async IIFE turns\n // that synchronous throw into a rejection of the IIFE's own promise,\n // so `.catch()` catches it too.\n (async () => resolveLive(tree, opts.locale))().catch((err: unknown) => {\n // The production wiring (`loadCanvasLiveValues`) never throws by its\n // own contract (\"Contained\", server/live-values.ts), but `deps` is\n // an injection seam — nothing at the TYPE level stops some other\n // caller wiring a `resolveLiveValues` that rejects or throws. Either\n // one here must not reject the whole `Promise.all`: that would\n // discard the collection pass's already-settled results and fail\n // this tree's ENTIRE resolution for a failure that was supposed to\n // cost it only the live sidecar — the same isolation\n // `resolveLiveValues` itself already gives one bad SOURCE among\n // several.\n deps.logger?.warn(\n { err },\n 'blocks: resolveLiveValues rejected; this tree draws no live values',\n )\n return undefined\n })\n : Promise.resolve(undefined),\n ])\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n // Omitted when the site registers no live sources at all — see\n // `ResolvedBlockTree.live`'s own docblock for why `sources` (not just\n // `values`) is what makes this non-empty. Projected to `{sources, values}`\n // rather than spread whole: `deps.resolveLiveValues` returns the blocks\n // package's own `LiveValues`, which also carries `asked` (canvas-merge\n // bookkeeping — which keys were requested, for a reader this one-shot\n // render has none of). The wire type `ResolvedLiveValues` deliberately\n // excludes it; spreading the raw object would put it on the wire anyway,\n // since nothing else here strips it.\n ...(live !== undefined &&\n Object.keys(live.sources).length > 0 && { live: { sources: live.sources, values: live.values } }),\n // NOT gated on `live` or on this tree binding anything: a plain, typed-in\n // Price needs the currency too.\n ...(deps.currency !== undefined && { currency: deps.currency }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale, spots: true })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n // block-library F012 — the SAME resolver the canvas's own entry points\n // call (`loadCanvasLiveValues`), so a price cannot bind through a\n // different path than the one the author saw while editing. `currency`\n // does not depend on the locale passed in — `canvasSiteFormat` only\n // needs one to echo back as `.locale`, which this caller discards.\n //\n // Wired only when the site actually declared a source — mirroring\n // `collections` above. Unconditional wiring would claim one budget unit\n // per tree on every site regardless of whether it uses live values at\n // all, which on a list response competes with media/link claims for no\n // reason on the overwhelming majority of trees that carry no live-bound\n // field.\n ...(hasConfiguredLiveSources() && {\n resolveLiveValues: (roots: readonly unknown[], locale: string) =>\n loadCanvasLiveValues(roots, { locale, logger: app.logger }),\n }),\n currency: canvasSiteFormat(options.defaultLocale).currency,\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionFieldLookup,\n type CollectionResult,\n type CollectionSourceSpec,\n canvasSiteFormat,\n collectBoundColumnsInTree,\n createCollectionReader,\n type DocumentDetail,\n type DocumentDetailPart,\n detailNeedsBody,\n detailPartsInTree,\n effectiveCollectionSources,\n effectiveDetailSpecs,\n type FindManyClientLike,\n hasConfiguredLiveSources,\n type LiveValues,\n loadCanvasLiveValues,\n materializeServerTemplate,\n readSpotCrop,\n resolveDocumentDetail,\n resolveLinks,\n resolveTreeCollections,\n type SpotCrop,\n sameSpotCrop,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n /**\n * The files cut for each spot crop the photograph has files for (images\n * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and\n * the photograph records some. Never embedded in a tree: {@link writeSlot}\n * picks the one list a placement's own `crop` names and drops the rest.\n */\n spotSources?: BlockSpotSources[]\n}\n\n/** One spot crop's files, as the media resolver publishes them. */\nexport interface BlockSpotSources {\n crop: { x: number; y: number; w: number; h: number }\n sources: Array<{ url: string; width: number; height: number }>\n avifSources?: Array<{ url: string; width: number; height: number }>\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: {\n locale?: string | undefined\n /**\n * Also publish the files cut for spot crops (images PR16). Asked by the\n * BLOCK walk only — a top-level media field has no placement crop, so its\n * projection stays exactly what it was.\n */\n spots?: boolean | undefined\n },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n /** What this tree's live-bound Price fields resolved to (block-library F012). */\n readonly live?: LiveValues\n /** The site's currency (block-library F012) — present whenever the site configures one. */\n readonly currency?: string\n /**\n * The row this tree is drawn for, projected through its entity's detail\n * declaration (block-library PR16) — price, facts, a sanitised HTML body, a\n * viewer. Absent unless the app declares a detail for the entity and the row\n * has a value for at least one declared part.\n */\n readonly detail?: DocumentDetail\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n /** Name AND field restriction (kulli-editor F019) — `serverCollectionFieldLookup()`. */\n readonly sourceFieldOf: CollectionFieldLookup\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n /**\n * Resolve this tree's live-bound Price values (block-library PR03, F012).\n * Omit and a live-bound Price draws nothing — same deny-by-default rule as\n * `collections`. Defaults to `loadCanvasLiveValues`, the SAME resolver the\n * canvas's own entry points call, so a price cannot bind through a\n * different path (or a different source set) than the one the author saw\n * while editing.\n */\n resolveLiveValues?: ((roots: readonly unknown[], locale: string) => Promise<LiveValues>) | undefined\n /**\n * The site's currency (ISO 4217), from `blocksPlugin({ currency })`\n * (block-library F012). Independent of `resolveLiveValues`: a plain,\n * typed-in Price needs it too, to show a currency symbol rather than a bare\n * number.\n */\n currency?: string | undefined\n /**\n * Project the row a tree is drawn for through its entity's detail\n * declaration (block-library PR16). Omit and no tree carries a `detail` —\n * deny-by-default, like `collections`. Query-free: the row is the one the\n * caller is already delivering, under the caller's own access.\n *\n * `parts` are the detail parts the TREE's blocks draw; only those are\n * projected, and a tree that draws none is never asked about.\n */\n resolveDetail?:\n | ((\n entity: string,\n row: Readonly<Record<string, unknown>>,\n locale: string,\n parts: ReadonlySet<DocumentDetailPart>,\n ) => DocumentDetail | undefined)\n | undefined\n /**\n * Would projecting `parts` for `entity` sanitise an HTML body? That is the\n * one detail part with a real CPU cost, so the resolver charges the\n * response's budget for it. Omit and no projection is charged.\n */\n detailNeedsBody?:\n | ((\n entity: string,\n parts: ReadonlySet<DocumentDetailPart>,\n row: Readonly<Record<string, unknown>>,\n ) => boolean)\n | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection | 'crop', true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n // A STORED key (images PR16, D002): the one crop a placement carries — author\n // data about the spot, validated at every write. Listed in the same commit\n // that let the ref carry it: an unrecognised key makes the detector walk\n // past the ref, and its stored url would cross unresolved (CLAUDE.md,\n // Images fact 2). Kept on the embedded value, so a second pass still sees it.\n crop: true,\n // Projection-only, and never embedded — `writeSlot` consumes it. Listed\n // because the map is tied to the projection's keys by type.\n spotSources: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n // A spot crop is an object. A project prop holding something else under\n // `crop` is left alone rather than claimed. An object that is not a VALID\n // crop is still a media ref — the write gate refuses those, and a stored one\n // is drawn around the focal point (`readSpotCrop` is lenient) rather than\n // being left unresolved with its stored url.\n if (value.crop !== undefined && value.crop !== null && !isRecord(value.crop)) return null\n if (value.spotSources !== undefined && value.spotSources !== null) {\n if (!Array.isArray(value.spotSources)) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n /** The placement's own crop (images PR16), read when the slot was found. */\n readonly crop?: SpotCrop | undefined\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null)\n out.push({ container: node, key: index, id, crop: readSpotCrop(node[index]) })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id, crop: readSpotCrop(value) })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\n/**\n * The files cut for THIS placement's crop, or `undefined` when the worker has\n * not cut them yet (or the placement has no crop). Matched on the four-decimal\n * grid (`sameSpotCrop`).\n */\nfunction spotSourcesFor(\n projection: BlockMediaProjection,\n crop: SpotCrop | undefined,\n): BlockSpotSources | undefined {\n if (crop === undefined || projection.spotSources === undefined) return undefined\n return projection.spotSources.find(\n (spot) => sameSpotCrop(spot.crop, crop) && spot.sources.length > 0,\n )\n}\n\n/**\n * The embedded value for a placement with a spot crop (images PR16, D002).\n *\n * - **Its files exist** — the placement's value becomes THOSE files: `sources`\n * holds the crop's list under `natural` (the crop IS this placement's\n * picture, at the crop's own ratio), `url`/`width`/`height` are the largest\n * file's, and the focal point is the centre (the file is already the crop).\n * So a render selects from the crop's files alone — the original is never\n * appended above them, because it is no longer larger than the value's own\n * width — and CSS has nothing left to crop. `avifSources` follows only when\n * the crop's AVIF files are exactly its WebP list, re-encoded.\n * - **They do not exist yet** — the projection as any placement gets it: the\n * focal-point files, never the original (D002). The crop is kept on the\n * value either way, so a second pass sees the same placement.\n *\n * A placement with NO crop never reaches this function, so its embedded value\n * — and the markup a render makes of it — is byte-identical to before.\n */\nfunction embedCropped(projection: BlockMediaProjection, crop: SpotCrop): Record<string, unknown> {\n const out = toEmbeddedProjection(projection)\n const spot = spotSourcesFor(projection, crop)\n const largest = spot?.sources[spot.sources.length - 1]\n if (spot !== undefined && largest !== undefined) {\n out.url = largest.url\n out.width = largest.width\n out.height = largest.height\n out.focalPoint = { x: 0.5, y: 0.5 }\n out.sources = { natural: spot.sources }\n const avif = spot.avifSources\n const avifMatches =\n avif !== undefined &&\n avif.length === spot.sources.length &&\n avif.every(\n (file, i) =>\n file.width === spot.sources[i]?.width && file.height === spot.sources[i]?.height,\n )\n if (avifMatches) out.avifSources = { natural: avif }\n else delete out.avifSources\n }\n out.crop = { x: crop.x, y: crop.y, w: crop.w, h: crop.h }\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written =\n value === null\n ? null\n : slot.crop === undefined\n ? toEmbeddedProjection(value)\n : embedCropped(value, slot.crop)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n // block-library F012 — the delivered-site half of PR03's live-source\n // contract. Deny-by-default like collections: no `resolveLiveValues`\n // wired, no `live` sidecar, and a bound Price draws nothing (the same\n // thing the canvas draws for a site with no sources at all).\n //\n // 🔴 CLAIMED against the SAME shared budget every other resolution kind in\n // this function draws from, same as `collections`' own `claimCollection`.\n // Without it, a list response resolving N rows each binding a live Price\n // would call the site's own `LiveSourceSpec.resolve` — an ARBITRARY,\n // site-registered callback, explicitly documented as reaching outside this\n // process (`core/live-sources.ts`: \"never elevate… it runs inside the\n // request's own context\") — once per row with no ceiling at all: exactly\n // the amplification CLAUDE.md's no-unbounded-fan-out section forbids,\n // reachable the moment any site registers a live source. One unit, because\n // a tree either asks its source(s) once or not at all — unlike media/links,\n // there is no per-item count to charge, and a 1-unit claim is what the\n // collection pass already charges for the same \"one call, unknown size\"\n // shape.\n const resolveLive = deps.resolveLiveValues\n const liveGranted = resolveLive !== undefined ? opts.budget.claim('block', 1) : 0\n\n // Run alongside collections rather than after: neither's result feeds the\n // other, and `resolveLiveValues` can take up to several seconds against a\n // slow or down upstream (`LIVE_DOCUMENT_TIMEOUT_MS`), which otherwise\n // stacked on top of collection resolution's own latency for no reason.\n const [{ results: collections, cardMedia }, live] = await Promise.all([\n resolveCollectionsIn(tree, opts, deps),\n liveGranted > 0 && resolveLive !== undefined\n ? // `(async () => …)()` rather than calling `resolveLive` directly: its\n // type (`BlockResolverDeps['resolveLiveValues']`) only requires a\n // `Promise<LiveValues>` RETURN, not an `async` implementation, so a\n // misbehaving caller-supplied resolver could throw SYNCHRONOUSLY\n // before ever producing a promise for `.catch()` to attach to — which\n // would reject `Promise.all` (and this whole function) before the\n // `.catch()` below is ever reached. Wrapping in an async IIFE turns\n // that synchronous throw into a rejection of the IIFE's own promise,\n // so `.catch()` catches it too.\n (async () => resolveLive(tree, opts.locale))().catch((err: unknown) => {\n // The production wiring (`loadCanvasLiveValues`) never throws by its\n // own contract (\"Contained\", server/live-values.ts), but `deps` is\n // an injection seam — nothing at the TYPE level stops some other\n // caller wiring a `resolveLiveValues` that rejects or throws. Either\n // one here must not reject the whole `Promise.all`: that would\n // discard the collection pass's already-settled results and fail\n // this tree's ENTIRE resolution for a failure that was supposed to\n // cost it only the live sidecar — the same isolation\n // `resolveLiveValues` itself already gives one bad SOURCE among\n // several.\n deps.logger?.warn(\n { err },\n 'blocks: resolveLiveValues rejected; this tree draws no live values',\n )\n return undefined\n })\n : Promise.resolve(undefined),\n ])\n\n // block-library PR16 — the row's detail, for the parts THIS tree's blocks\n // draw and no others. A tree with no detail block (every row of an ordinary\n // list response) is not projected at all.\n //\n // 🔴 One part costs real CPU: an HTML body is sanitised here, up to half a\n // megabyte of it, on a public request. So a projection that needs the body\n // claims one unit of the response's budget, exactly as the live pass does —\n // a list of a hundred legal documents sanitises as many bodies as the budget\n // has units, and the rest carry their other parts without one. It reads the\n // row already in hand and runs no query.\n //\n // Contained, because `deps` is an injection seam: a throwing projection costs\n // this tree its detail, not its blocks.\n let detail: DocumentDetail | undefined\n if (deps.resolveDetail !== undefined && opts.boundValues !== undefined) {\n try {\n let parts = detailPartsInTree(isUnknownArray(tree) ? tree : [])\n if (\n parts.has('body') &&\n deps.detailNeedsBody?.(opts.entity, parts, opts.boundValues) === true &&\n opts.budget.claim('block', 1) < 1\n ) {\n const withoutBody = new Set(parts)\n withoutBody.delete('body')\n parts = withoutBody\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'blocks: the response budget is spent; this tree is delivered without its HTML body',\n )\n }\n if (parts.size > 0) {\n detail = deps.resolveDetail(opts.entity, opts.boundValues, opts.locale, parts)\n }\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity },\n 'blocks: resolveDetail threw; this tree carries no detail',\n )\n }\n }\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n // Omitted when the site registers no live sources at all — see\n // `ResolvedBlockTree.live`'s own docblock for why `sources` (not just\n // `values`) is what makes this non-empty. Projected to `{sources, values}`\n // rather than spread whole: `deps.resolveLiveValues` returns the blocks\n // package's own `LiveValues`, which also carries `asked` (canvas-merge\n // bookkeeping — which keys were requested, for a reader this one-shot\n // render has none of). The wire type `ResolvedLiveValues` deliberately\n // excludes it; spreading the raw object would put it on the wire anyway,\n // since nothing else here strips it.\n ...(live !== undefined &&\n Object.keys(live.sources).length > 0 && { live: { sources: live.sources, values: live.values } }),\n // NOT gated on `live` or on this tree binding anything: a plain, typed-in\n // Price needs the currency too.\n ...(deps.currency !== undefined && { currency: deps.currency }),\n // Omitted unless the row has a value for a declared part.\n ...(detail !== undefined && { detail }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale, spots: true })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n // Validated once per app; a rejected declaration was logged at boot.\n const detailSpecs = effectiveDetailSpecs(app).specs\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n // block-library F012 — the SAME resolver the canvas's own entry points\n // call (`loadCanvasLiveValues`), so a price cannot bind through a\n // different path than the one the author saw while editing. `currency`\n // does not depend on the locale passed in — `canvasSiteFormat` only\n // needs one to echo back as `.locale`, which this caller discards.\n //\n // Wired only when the site actually declared a source — mirroring\n // `collections` above. Unconditional wiring would claim one budget unit\n // per tree on every site regardless of whether it uses live values at\n // all, which on a list response competes with media/link claims for no\n // reason on the overwhelming majority of trees that carry no live-bound\n // field.\n ...(hasConfiguredLiveSources() && {\n resolveLiveValues: (roots: readonly unknown[], locale: string) =>\n loadCanvasLiveValues(roots, { locale, logger: app.logger }),\n }),\n currency: canvasSiteFormat(options.defaultLocale).currency,\n // block-library PR16 — wired only when the app declares a detail for at\n // least one entity, mirroring `collections` and `resolveLiveValues`.\n ...(detailSpecs.size > 0 && {\n resolveDetail: (\n entity: string,\n row: Readonly<Record<string, unknown>>,\n locale: string,\n parts: ReadonlySet<DocumentDetailPart>,\n ) => {\n const spec = detailSpecs.get(entity)\n return spec === undefined ? undefined : resolveDocumentDetail(spec, row, locale, parts)\n },\n detailNeedsBody: (\n entity: string,\n parts: ReadonlySet<DocumentDetailPart>,\n row: Readonly<Record<string, unknown>>,\n ) => {\n const spec = detailSpecs.get(entity)\n return spec !== undefined && detailNeedsBody(spec, parts, row)\n },\n }),\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
|
package/dist/client.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./versionable-Br_G5VGR.mjs";import{n as t,r as n,t as r}from"./errors-NRNILhhm.mjs";import{t as i}from"./content-config-store-8omQF5p5.mjs";import{a,d as o,f as s,h as c,i as l,l as u,m as d,n as f,o as p,p as m,r as h,s as g,t as _,u as v}from"./versions-DLrR7hIZ.mjs";import{n as y}from"./plugin-BmxghXzV.mjs";import{t as b}from"./locale-utils-B0mFoV47.mjs";import{i as x,n as S,r as C}from"./context-D7eEItCY.mjs";import{blockTreeFieldsOf as w,buildPartitionCondition as T}from"@murumets-ee/entity";import{getApp as E,getContext as D,requireSiteFor as O}from"@murumets-ee/core";import{schemaRegistry as k}from"@murumets-ee/db";import{and as A,inArray as j}from"drizzle-orm";import"server-only";import{createAdminClient as M}from"@murumets-ee/core/clients";function N(e){return{id:e.id,entityId:e.entityId,locale:e.locale,data:e.data,createdBy:e.createdBy,createdByName:e.createdByName??null,createdAt:e.createdAt,updatedAt:e.updatedAt}}async function P(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);e.admin.assertBlockTreesValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e))?await s.findOne({entityId:n,locale:i}):null;await e.admin.assertMediaKindsValid(c,l?.data);let u=new Date,d=a?.id??``,f=a?.name??null;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:d,createdByName:f,createdAt:u,updatedAt:u},{target:[`entityId`,`locale`],set:{data:c,updatedAt:u}}))}const F=[`slug`,`slugMode`,`parent`];async function I(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);if(Object.keys(c).length===0){let e=await s.findOne({entityId:n,locale:i});return e?N(e):null}e.admin.assertBlockTreesValid(c),e.admin.assertUpdateValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e)),u=e.entity.frontend?.path!==void 0&&F.some(e=>Object.hasOwn(c,e)),d=l||u?await s.findOne({entityId:n,locale:i}):null;if(await e.admin.assertMediaKindsValid(c,d?.data),u){let t={...d?.data??{},...c};if(i===`_`)await e.admin.assertPathAvailable(n,t);else{let r=e.entity.allFields,a={},o={};for(let[e,n]of Object.entries(t))r[e]?.translatable===!0?a[e]=n:o[e]=n;let s=e=>F.some(t=>Object.hasOwn(e,t)&&Object.hasOwn(c,t));s(a)&&await e.admin.assertPathAvailable(n,a,i),s(o)&&await e.admin.assertPathAvailable(n,o)}}let f=new Date;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:a?.id??``,createdByName:a?.name??null,createdAt:f,updatedAt:f},{target:[`entityId`,`locale`],set:{updatedAt:f},mergeJsonb:[`data`]}))}async function L(e,t,n=`_`){b(n);let r=await C(e);if(!r)return null;let i=await r.findOne({entityId:t,locale:n});return i?N(i):null}async function R(e,t,n=`_`){b(n);let r=await C(e);return r?(await r.deleteMany({entityId:t,locale:n})).length>0:!1}async function z(e,n,i=`_`,a){if(b(i),!await C(e))throw new t(e.entity.name,`drafts`);let c=e.versionableOptions?await e.admin.findById(n,m(i)):null;return e.db.transaction(async l=>{let u={...e,db:l},d=await C(u);if(!d)throw new t(e.entity.name,`drafts`);let[p]=await d.deleteMany({entityId:n,locale:i});if(!p)throw new r(n,i);let{publishedAt:m,...h}=p.data??{},g=await s(u,n,o(e.entity,{...h,status:`published`}),i,l),_=B(e.entity,h);return _!==void 0&&await e.admin.updateInternal(n,{_overrides:_},{tx:l}),e.versionableOptions&&await f(u,n,g,c,i,a),g})}function B(e,t){if(!(e.behaviors??[]).some(e=>e.name===`withOverrides`))return;let n=e.allFields._overrides;if(n===void 0||n.internal!==!0)return;let r=t._overrides;if(!(typeof r!=`object`||!r||Array.isArray(r)))return r}async function V(e,t,n=`_`){b(n);let r=await C(e);return r?r.exists({entityId:t,locale:n}):!1}async function H(e,t=`_`){b(t);let n=await C(e);return n?n.count({locale:t}):0}async function U(e,t,n=`_`){b(n);let r=new Set;if(t.length===0)return r;let i=await C(e);if(!i)return r;let a=await i.findMany({where:{entityId:{in:t},locale:n}});for(let e of a)r.add(e.entityId);return r}async function W(e,t,n=`_`){b(n);let r=await L(e,t,n);if(!r)return null;let a=i().defaultLocale,o=n===`_`?{defaultLocale:a}:{locale:n,defaultLocale:a},s=await e.admin.findById(t,o);if(!s)return null;let l=e.entity.allFields,u=s,f=r.data,p={};for(let e of Object.keys(f))p[e]=u[e];let m=c(p,f,l);for(let e of m)e.type===`blockTree`&&(e.blockDiffs=d(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:m,current:s,draft:r}}async function G(e,t){let n=(await q(e,[t])).get(t);return n?Object.values(n):[]}async function K(e,n,r,a){b(r);let o=i();if(r===o.defaultLocale){let t={status:a};a===`published`&&(t.publishedAt=new Date),await e.admin.update(n,t);return}let s=await x(e);if(!s)throw new t(e.entity.name,`localeStatus`);let c={status:a};await e.admin.updateForLocale(n,c,r,{defaultLocale:o.defaultLocale,localeStatusWriter:(e,t,n)=>y(s,e,t,n)})}async function q(e,t){let n=new Map;if(t.length===0)return n;let r=await x(e);if(!r)return n;let i=await r.findMany({where:{entityId:{in:t}}});for(let e of i){let t=e.entityId,r=e.locale,i=n.get(t);i||(i={},n.set(t,i)),i[r]={locale:r,status:e.status,publishedAt:e.publishedAt??null}}return n}function J(){let e=i(),t=D();return{locales:(t?.availableLocales??e.locales.map(e=>e.code)).map(e=>({code:e})),defaultLocale:t?.defaultLocale??e.defaultLocale}}function Y(e,t,n,r){let i={};for(let a of n){if(a.code===r)continue;let n=t?.get(a.code)??{},o=[];for(let t of e)(n[t]===void 0||n[t]===null)&&o.push(t);i[a.code]={complete:o.length===0,total:e.length,translated:e.length-o.length,missing:o}}return i}async function X(e,t){return(await Z(e,[t])).get(t)??{}}async function Z(e,t){let n=new Map;if(t.length===0)return n;let{locales:r,defaultLocale:i}=J(),a=Object.entries(e.entity.allFields).filter(([e,t])=>t.translatable).map(([e])=>e);if(a.length===0)return n;let o=k.get(`${e.entity.name}_translations`);if(!o)return n;let s=await O(e.entity),c=j(o.entityId,t),l=await e.db.select().from(o).where(s===void 0?c:A(c,T(o,{site:s},e.entity.name))),u=new Map;for(let e of l){let t=e,n=t.entityId,r=t.locale,i={};for(let e of a)i[e]=t[e];let o=u.get(n);o||(o=new Map,u.set(n,o)),o.set(r,i)}for(let e of t)n.set(e,Y(a,u.get(e),r,i));return n}async function Q(e,t){let{locales:r,defaultLocale:i}=J(),a=t?.locale??r.find(e=>e.code!==i)?.code;if(!a)return{total:0,complete:0,partial:0,missing:0};if(!r.some(e=>e.code===a))throw new n(a,r.map(e=>e.code));let o=await e.admin.findMany();if(o.length===0)return{total:0,complete:0,partial:0,missing:0};let s=await Z(e,o.map(e=>e.id)),c=0,l=0,u=0;for(let e of o){let t=s.get(e.id)?.[a];!t||t.translated===0?u++:t.complete?c++:l++}return{total:o.length,complete:c,partial:l,missing:u}}var $=class{ctx;constructor(t){let n=e(t.entity.behaviors);this.ctx=S(t,n)}async create(e){let t=await this.ctx.admin.create(e);return this.ctx.versionableOptions&&await g(this.ctx,t.id,t,null,!1),t}findById(e,t){return this.ctx.admin.findById(e,t)}findMany(e){return this.ctx.admin.findMany(e)}async update(e,t){let n=null;this.ctx.versionableOptions&&(n=await this.ctx.admin.findById(e));let r=await this.ctx.admin.update(e,t);if(this.ctx.versionableOptions&&n){let t=v(n,r),i=await g(this.ctx,e,r,t,!1);if(i!==void 0)return await u(this.ctx,e,i),await a(this.ctx,e),{...r,_version:i};await a(this.ctx,e)}return r}delete(e){return this.ctx.admin.delete(e)}saveTranslation(e,t,n){return this.ctx.admin.saveTranslation(e,t,n)}deleteTranslation(e,t){return this.ctx.admin.deleteTranslation(e,t)}getVersions(e,t){return l(this.ctx,e,t)}getVersion(e,t,n){return h(this.ctx,e,t,n)}compareVersions(e,t,n,r){return _(this.ctx,e,t,n,r)}restoreVersion(e,t,n){return p(this.ctx,e,t,n)}pruneVersions(e,t){return a(this.ctx,e,t)}createVersionSnapshot(e,t,n,r,i){return f(this.ctx,e,t,n,r,i)}getTranslationStatus(e){return X(this.ctx,e)}getTranslationStatusBatch(e){return Z(this.ctx,e)}getTranslationReport(e){return Q(this.ctx,e)}getLocaleStatus(e){return G(this.ctx,e)}setLocaleStatus(e,t,n){return K(this.ctx,e,t,n)}getLocaleStatusBatch(e){return q(this.ctx,e)}saveDraft(e,t,n,r){return P(this.ctx,e,t,n,r)}mergeDraft(e,t,n,r){return I(this.ctx,e,t,n,r)}getDraft(e,t){return L(this.ctx,e,t)}deleteDraft(e,t){return R(this.ctx,e,t)}publishDraft(e,t,n){return z(this.ctx,e,t,n)}hasDraft(e,t){return V(this.ctx,e,t)}countDrafts(e){return H(this.ctx,e)}hasDraftBatch(e,t){return U(this.ctx,e,t)}compareDraftWithCurrent(e,t){return W(this.ctx,e,t)}};function ee(e){return new $({admin:M(e),entity:e,logger:E().logger.child({content:e.name})})}export{$ as ContentClient,ee as createContentClient};
|
|
1
|
+
import{t as e}from"./versionable-Br_G5VGR.mjs";import{n as t,r as n,t as r}from"./errors-NRNILhhm.mjs";import{t as i}from"./content-config-store-8omQF5p5.mjs";import{a,d as o,f as s,h as c,i as l,l as u,m as d,n as f,o as p,p as m,r as h,s as g,t as _,u as v}from"./versions-DLrR7hIZ.mjs";import{n as y}from"./plugin-BmxghXzV.mjs";import{t as b}from"./locale-utils-B0mFoV47.mjs";import{i as x,n as S,r as C}from"./context-D7eEItCY.mjs";import{blockTreeFieldsOf as w,buildPartitionCondition as T}from"@murumets-ee/entity";import{getApp as E,getContext as D,requireSiteFor as O}from"@murumets-ee/core";import{schemaRegistry as k}from"@murumets-ee/db";import{and as A,inArray as j}from"drizzle-orm";import"server-only";import{createAdminClient as M}from"@murumets-ee/core/clients";function N(e){return{id:e.id,entityId:e.entityId,locale:e.locale,data:e.data,createdBy:e.createdBy,createdByName:e.createdByName??null,createdAt:e.createdAt,updatedAt:e.updatedAt}}async function P(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);e.admin.assertBlockTreesValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e))?await s.findOne({entityId:n,locale:i}):null;await e.admin.assertBlockTreesStrictForCaller(c,()=>l?.data),await e.admin.assertMediaKindsValid(c,l?.data);let u=new Date,d=a?.id??``,f=a?.name??null;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:d,createdByName:f,createdAt:u,updatedAt:u},{target:[`entityId`,`locale`],set:{data:c,updatedAt:u}}))}const F=[`slug`,`slugMode`,`parent`];async function I(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);if(Object.keys(c).length===0){let e=await s.findOne({entityId:n,locale:i});return e?N(e):null}e.admin.assertBlockTreesValid(c),e.admin.assertUpdateValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e)),u=e.entity.frontend?.path!==void 0&&F.some(e=>Object.hasOwn(c,e)),d=l||u?await s.findOne({entityId:n,locale:i}):null;if(await e.admin.assertBlockTreesStrictForCaller(c,()=>d?.data),await e.admin.assertMediaKindsValid(c,d?.data),u){let t={...d?.data??{},...c};if(i===`_`)await e.admin.assertPathAvailable(n,t);else{let r=e.entity.allFields,a={},o={};for(let[e,n]of Object.entries(t))r[e]?.translatable===!0?a[e]=n:o[e]=n;let s=e=>F.some(t=>Object.hasOwn(e,t)&&Object.hasOwn(c,t));s(a)&&await e.admin.assertPathAvailable(n,a,i),s(o)&&await e.admin.assertPathAvailable(n,o)}}let f=new Date;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:a?.id??``,createdByName:a?.name??null,createdAt:f,updatedAt:f},{target:[`entityId`,`locale`],set:{updatedAt:f},mergeJsonb:[`data`]}))}async function L(e,t,n=`_`){b(n);let r=await C(e);if(!r)return null;let i=await r.findOne({entityId:t,locale:n});return i?N(i):null}async function R(e,t,n=`_`){b(n);let r=await C(e);return r?(await r.deleteMany({entityId:t,locale:n})).length>0:!1}async function z(e,n,i=`_`,a){if(b(i),!await C(e))throw new t(e.entity.name,`drafts`);let c=e.versionableOptions?await e.admin.findById(n,m(i)):null;return e.db.transaction(async l=>{let u={...e,db:l},d=await C(u);if(!d)throw new t(e.entity.name,`drafts`);let[p]=await d.deleteMany({entityId:n,locale:i});if(!p)throw new r(n,i);let{publishedAt:m,...h}=p.data??{},g=await s(u,n,o(e.entity,{...h,status:`published`}),i,l),_=B(e.entity,h);return _!==void 0&&await e.admin.updateInternal(n,{_overrides:_},{tx:l}),e.versionableOptions&&await f(u,n,g,c,i,a),g})}function B(e,t){if(!(e.behaviors??[]).some(e=>e.name===`withOverrides`))return;let n=e.allFields._overrides;if(n===void 0||n.internal!==!0)return;let r=t._overrides;if(!(typeof r!=`object`||!r||Array.isArray(r)))return r}async function V(e,t,n=`_`){b(n);let r=await C(e);return r?r.exists({entityId:t,locale:n}):!1}async function H(e,t=`_`){b(t);let n=await C(e);return n?n.count({locale:t}):0}async function U(e,t,n=`_`){b(n);let r=new Set;if(t.length===0)return r;let i=await C(e);if(!i)return r;let a=await i.findMany({where:{entityId:{in:t},locale:n}});for(let e of a)r.add(e.entityId);return r}async function W(e,t,n=`_`){b(n);let r=await L(e,t,n);if(!r)return null;let a=i().defaultLocale,o=n===`_`?{defaultLocale:a}:{locale:n,defaultLocale:a},s=await e.admin.findById(t,o);if(!s)return null;let l=e.entity.allFields,u=s,f=r.data,p={};for(let e of Object.keys(f))p[e]=u[e];let m=c(p,f,l);for(let e of m)e.type===`blockTree`&&(e.blockDiffs=d(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:m,current:s,draft:r}}async function G(e,t){let n=(await q(e,[t])).get(t);return n?Object.values(n):[]}async function K(e,n,r,a){b(r);let o=i();if(r===o.defaultLocale){let t={status:a};a===`published`&&(t.publishedAt=new Date),await e.admin.update(n,t);return}let s=await x(e);if(!s)throw new t(e.entity.name,`localeStatus`);let c={status:a};await e.admin.updateForLocale(n,c,r,{defaultLocale:o.defaultLocale,localeStatusWriter:(e,t,n)=>y(s,e,t,n)})}async function q(e,t){let n=new Map;if(t.length===0)return n;let r=await x(e);if(!r)return n;let i=await r.findMany({where:{entityId:{in:t}}});for(let e of i){let t=e.entityId,r=e.locale,i=n.get(t);i||(i={},n.set(t,i)),i[r]={locale:r,status:e.status,publishedAt:e.publishedAt??null}}return n}function J(){let e=i(),t=D();return{locales:(t?.availableLocales??e.locales.map(e=>e.code)).map(e=>({code:e})),defaultLocale:t?.defaultLocale??e.defaultLocale}}function Y(e,t,n,r){let i={};for(let a of n){if(a.code===r)continue;let n=t?.get(a.code)??{},o=[];for(let t of e)(n[t]===void 0||n[t]===null)&&o.push(t);i[a.code]={complete:o.length===0,total:e.length,translated:e.length-o.length,missing:o}}return i}async function X(e,t){return(await Z(e,[t])).get(t)??{}}async function Z(e,t){let n=new Map;if(t.length===0)return n;let{locales:r,defaultLocale:i}=J(),a=Object.entries(e.entity.allFields).filter(([e,t])=>t.translatable).map(([e])=>e);if(a.length===0)return n;let o=k.get(`${e.entity.name}_translations`);if(!o)return n;let s=await O(e.entity),c=j(o.entityId,t),l=await e.db.select().from(o).where(s===void 0?c:A(c,T(o,{site:s},e.entity.name))),u=new Map;for(let e of l){let t=e,n=t.entityId,r=t.locale,i={};for(let e of a)i[e]=t[e];let o=u.get(n);o||(o=new Map,u.set(n,o)),o.set(r,i)}for(let e of t)n.set(e,Y(a,u.get(e),r,i));return n}async function Q(e,t){let{locales:r,defaultLocale:i}=J(),a=t?.locale??r.find(e=>e.code!==i)?.code;if(!a)return{total:0,complete:0,partial:0,missing:0};if(!r.some(e=>e.code===a))throw new n(a,r.map(e=>e.code));let o=await e.admin.findMany();if(o.length===0)return{total:0,complete:0,partial:0,missing:0};let s=await Z(e,o.map(e=>e.id)),c=0,l=0,u=0;for(let e of o){let t=s.get(e.id)?.[a];!t||t.translated===0?u++:t.complete?c++:l++}return{total:o.length,complete:c,partial:l,missing:u}}var $=class{ctx;constructor(t){let n=e(t.entity.behaviors);this.ctx=S(t,n)}async create(e){let t=await this.ctx.admin.create(e);return this.ctx.versionableOptions&&await g(this.ctx,t.id,t,null,!1),t}findById(e,t){return this.ctx.admin.findById(e,t)}findMany(e){return this.ctx.admin.findMany(e)}async update(e,t){let n=null;this.ctx.versionableOptions&&(n=await this.ctx.admin.findById(e));let r=await this.ctx.admin.update(e,t);if(this.ctx.versionableOptions&&n){let t=v(n,r),i=await g(this.ctx,e,r,t,!1);if(i!==void 0)return await u(this.ctx,e,i),await a(this.ctx,e),{...r,_version:i};await a(this.ctx,e)}return r}delete(e){return this.ctx.admin.delete(e)}saveTranslation(e,t,n){return this.ctx.admin.saveTranslation(e,t,n)}deleteTranslation(e,t){return this.ctx.admin.deleteTranslation(e,t)}getVersions(e,t){return l(this.ctx,e,t)}getVersion(e,t,n){return h(this.ctx,e,t,n)}compareVersions(e,t,n,r){return _(this.ctx,e,t,n,r)}restoreVersion(e,t,n){return p(this.ctx,e,t,n)}pruneVersions(e,t){return a(this.ctx,e,t)}createVersionSnapshot(e,t,n,r,i){return f(this.ctx,e,t,n,r,i)}getTranslationStatus(e){return X(this.ctx,e)}getTranslationStatusBatch(e){return Z(this.ctx,e)}getTranslationReport(e){return Q(this.ctx,e)}getLocaleStatus(e){return G(this.ctx,e)}setLocaleStatus(e,t,n){return K(this.ctx,e,t,n)}getLocaleStatusBatch(e){return q(this.ctx,e)}saveDraft(e,t,n,r){return P(this.ctx,e,t,n,r)}mergeDraft(e,t,n,r){return I(this.ctx,e,t,n,r)}getDraft(e,t){return L(this.ctx,e,t)}deleteDraft(e,t){return R(this.ctx,e,t)}publishDraft(e,t,n){return z(this.ctx,e,t,n)}hasDraft(e,t){return V(this.ctx,e,t)}countDrafts(e){return H(this.ctx,e)}hasDraftBatch(e,t){return U(this.ctx,e,t)}compareDraftWithCurrent(e,t){return W(this.ctx,e,t)}};function ee(e){return new $({admin:M(e),entity:e,logger:E().logger.child({content:e.name})})}export{$ as ContentClient,ee as createContentClient};
|
|
2
2
|
//# sourceMappingURL=client.mjs.map
|
package/dist/client.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"sources":["../src/drafts.ts","../src/locale-status.ts","../src/translation-status.ts","../src/client.ts"],"sourcesContent":["/**\n * Draft-overlay operations.\n *\n * Capability: requires the entity to declare `publishable()`. Reads\n * (`getDraft`, `hasDraft`, `countDrafts`, `hasDraftBatch`) silently\n * return falsy when the entity doesn't support drafts; writes\n * (`saveDraft`, `mergeDraft`, `publishDraft`) throw `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport { blockTreeFieldsOf, type FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { DraftNotFoundError, EntityCapabilityError } from './errors.js'\nimport {\n applyLocaleRoutedUpdate,\n localeFindOptions,\n} from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getDraftsClient } from './internal/context.js'\nimport { projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { DraftsTable } from './schema/drafts.js'\nimport type { DraftEntry } from './types.js'\nimport { createVersionSnapshot } from './versions.js'\n\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\n\nfunction toDraftEntry(row: DraftRow): DraftEntry {\n return {\n id: row.id,\n entityId: row.entityId,\n locale: row.locale,\n data: row.data,\n createdBy: row.createdBy,\n createdByName: row.createdByName ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }\n}\n\n/**\n * Save (upsert) a draft for an entity+locale. One draft per\n * entity+locale — overwrites any existing draft.\n *\n * `data` is projected through the entity's declared field whitelist\n * BEFORE it is stored (same `projectToEntityFields` call `publishDraft`\n * makes on the way out). Storing the caller's JSON verbatim let a draft\n * carry keys the entity never declared, identity/audit columns and\n * `internal: true` fields; publishing filtered them, but `getDraft` and\n * `compareDraftWithCurrent` echoed them straight back to the admin UI,\n * and the drafts table accumulated arbitrary caller-controlled JSON\n * (`plan/admin-api-hardening` F002). Validating at the write closes the\n * asymmetry — the stored draft is now always a subset of what the entity\n * can actually hold.\n */\nexport async function saveDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n data: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>>> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const projected = projectToEntityFields(ctx.entity, data as Record<string, unknown>)\n\n // `projectToEntityFields` is a field-NAME whitelist — it never inspects a\n // value, so a `field.blockTree()` column in a draft took arbitrary JSON, and\n // `editor-state` hands the stored draft back as the CANVAS BASE STATE\n // (editor F002). Publishing is already gated by the entity write path, but\n // waiting until publish means the editor loads a tree it cannot represent.\n // Same validator, same judgement — asked here because the draft blob carries\n // no Zod schema of its own.\n ctx.admin.assertBlockTreesValid(projected)\n // F116, same reasoning — and this is precisely the write path the finding\n // was filed against: dava's home-page DRAFT already holds a mismatched\n // media reference. Grandfathering against the draft ALREADY STORED (rather\n // than validating strictly) is what keeps that draft's next autosave\n // working instead of turning \"a broken image\" into \"a draft that can never\n // be saved again\".\n //\n // The lookup is skipped entirely when the PROJECTED payload carries no\n // block-tree field at all — `assertMediaKindsValid` would no-op anyway\n // (same optimization as `saveTranslation`'s), and most entities with\n // drafts have no block-tree field to begin with.\n const hasBlockTreeValue = blockTreeFieldsOf(ctx.entity).some(({ name }) =>\n Object.hasOwn(projected, name),\n )\n const existing = hasBlockTreeValue ? await client.findOne({ entityId, locale }) : null\n await ctx.admin.assertMediaKindsValid(\n projected,\n existing?.data as Record<string, unknown> | undefined,\n )\n\n const now = new Date()\n const createdBy = user?.id ?? ''\n const createdByName = user?.name ?? null\n\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: projected,\n createdBy,\n createdByName,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only update the mutable fields — preserve createdBy/createdAt.\n set: { data: projected, updatedAt: now },\n },\n )\n\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * The keys that move a document's canonical URL in the patterns the toolkit\n * ships (`[slug]`, `[parent.path]`) plus the slug mode that decides whether a\n * clash is refused or auto-suffixed. A merge naming one of them, on a ROUTABLE\n * entity, runs the path-conflict check (see `mergeDraft`); any other merge\n * skips its lookups — the staged draft, the row, and `toolkit_paths` (twice\n * under a parent).\n *\n * A pattern built from another field (`/[category]/[slug]`) is not covered:\n * staging only `category` is refused at Publish rather than here.\n */\nconst PATH_KEYS = ['slug', 'slugMode', 'parent'] as const\n\n/**\n * Stage a PARTIAL change: merge `patch` into the entity+locale draft, creating\n * the draft if there is none. Keys not in `patch` are left exactly as the draft\n * (or, absent a draft key, the live row) has them.\n *\n * This is the write every editing surface uses since editor D019 — \"every edit\n * on every surface saves to the document's one shared draft\". `saveDraft`\n * REPLACES the blob, which is right for a surface that owns the whole document\n * (the menu editor) and wrong for one that owns a few fields of it: page\n * settings saving `title` through `saveDraft` would erase the block edits the\n * canvas staged a second earlier. A draft is therefore a patch over the row, and\n * every reader that shows editable values reads row ⊕ draft.\n *\n * Judged like the entity PATCH it replaces, BEFORE it is stored:\n * - projected through the field whitelist (`internal`, undeclared keys dropped)\n * - `assertBlockTreesValid` + `assertMediaKindsValid` (grandfathered against the\n * stored draft, same as `saveDraft`)\n * - `assertUpdateValid` — the entity's own update schema. `saveDraft` never ran\n * it, which was harmless while the form's Publish still PATCHed; moving a\n * surface into the draft must not move its refusal to Publish, where the\n * author is no longer looking at the field.\n * - `assertPathAvailable` — the path-conflict guard, as a dry run, when the\n * merge moves the URL (see {@link PATH_KEYS}) on a routable entity (F150).\n *\n * 🔴 ONE STATEMENT, not read-modify-write. The merge is `data || patch` inside\n * `ON CONFLICT DO UPDATE` (`TableClient.upsert`'s `mergeJsonb`), so it cannot\n * lose a concurrent write: a read-then-upsert that interleaved with op-commit's\n * or a bound field's stage would write back the draft as it was read — silently\n * reverting the other writer's change. `writeDraft` merges the same way, so\n * neither can erase the other. Top-level `||`, a SHALLOW merge.\n *\n * Resolves `null` when the projected patch is EMPTY and no draft exists: nothing\n * the entity declares was sent, so nothing is staged and no empty draft row is\n * created.\n *\n * ⚠️ The rest of the write path's hooks — `pathParent()`'s cycle check, the\n * partial-unique slug index, `sluggable()`'s regeneration — still run only at\n * Publish, inside its transaction, where a refusal stops the whole publish. The\n * path check here answers \"as of now\"; Publish runs the real guard again.\n */\nexport async function mergeDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n patch: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const projected = projectToEntityFields(ctx.entity, patch as Record<string, unknown>)\n // Nothing the entity declares: stage nothing. Upserting `{}` would CREATE an\n // empty draft, and the document would then read \"Draft saved · not live\n // yet\" about a change that does not exist.\n if (Object.keys(projected).length === 0) {\n const current = await client.findOne({ entityId, locale })\n return current ? (toDraftEntry(current) as DraftEntry<InferUpdateInput<F>>) : null\n }\n ctx.admin.assertBlockTreesValid(projected)\n ctx.admin.assertUpdateValid(projected)\n const hasBlockTreeValue = blockTreeFieldsOf(ctx.entity).some(({ name }) =>\n Object.hasOwn(projected, name),\n )\n const touchesPath =\n ctx.entity.frontend?.path !== undefined &&\n PATH_KEYS.some((key) => Object.hasOwn(projected, key))\n const existing =\n hasBlockTreeValue || touchesPath ? await client.findOne({ entityId, locale }) : null\n await ctx.admin.assertMediaKindsValid(\n projected,\n existing?.data as Record<string, unknown> | undefined,\n )\n // A staged slug that another document's URL already holds is refused NOW,\n // beside the field the author typed it in — not first at Publish (editor\n // PR60, F150). Checked against what the draft will hold after this merge,\n // since an earlier stage may have set `slugMode` or `parent`.\n if (touchesPath) {\n const staged = {\n ...((existing?.data as Record<string, unknown> | undefined) ?? {}),\n ...projected,\n }\n if (locale === DEFAULT_LOCALE_SENTINEL) {\n await ctx.admin.assertPathAvailable(entityId, staged)\n } else {\n // Checked the way Publish writes it: `updateForLocale` sends a locale's\n // TRANSLATABLE fields through that locale's guard and the rest (a\n // `parent`, a non-translatable slug) through `update()`, whose guard\n // checks the default locale. One guard over everything would test a\n // shared `parent` against the wrong locale's URLs.\n const fields = ctx.entity.allFields as Record<string, { translatable?: boolean } | undefined>\n const translatable: Record<string, unknown> = {}\n const shared: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(staged)) {\n if (fields[key]?.translatable === true) translatable[key] = value\n else shared[key] = value\n }\n const moves = (part: Record<string, unknown>) =>\n PATH_KEYS.some((key) => Object.hasOwn(part, key) && Object.hasOwn(projected, key))\n if (moves(translatable)) await ctx.admin.assertPathAvailable(entityId, translatable, locale)\n if (moves(shared)) await ctx.admin.assertPathAvailable(entityId, shared)\n }\n }\n\n const now = new Date()\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: projected,\n createdBy: user?.id ?? '',\n createdByName: user?.name ?? null,\n createdAt: now,\n updatedAt: now,\n },\n { target: ['entityId', 'locale'], set: { updatedAt: now }, mergeJsonb: ['data'] },\n )\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * Get the draft for an entity+locale, or null if none exists (or the\n * entity doesn't support drafts).\n */\nexport async function getDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, locale })\n return row ? (toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>) : null\n}\n\n/**\n * Delete (discard) a draft for an entity+locale. Returns whether\n * anything was deleted; safely no-ops on entities without drafts.\n */\nexport async function deleteDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return false\n const deleted = await client.deleteMany({ entityId, locale })\n return deleted.length > 0\n}\n\n/**\n * Publish a draft: make THIS document's staged changes live.\n *\n * \"Publish\" means one thing on every surface (editor D019): the draft's fields\n * are written to the row for the draft's locale AND the row is put live. The\n * draft is then gone and, if versionable, a version snapshot records what went\n * live.\n *\n * Locale routing mirrors the admin PATCH handler (and the taxonomy fix\n * in 10a7cc2b): a default-locale draft writes the base row; a\n * non-default-locale draft routes translatable fields to\n * `saveTranslation` (→ `{entity}_translations`), `status` to the\n * per-locale status table, and only non-translatable fields (which have\n * no per-locale home) to the base row. Without this split, publishing\n * an `et` draft overwrote the default-locale base row wholesale.\n *\n * ## ONE TRANSACTION (editor PR60, R055 §4)\n *\n * The row write, the locale status, `_overrides`, the draft delete and the\n * version snapshot commit together or not at all. They used to be five separate\n * statements, so a failure part-way left a document whose content was live and\n * whose draft still claimed to be pending — or, worse, whose draft was gone while\n * the row write had not landed.\n *\n * The draft is CLAIMED first, by deleting it and publishing what the delete\n * returned. The delete takes the row lock, so an op-commit that is mid-flight on\n * the same draft either committed before (and its edit is in what we publish) or\n * blocks until we commit and then retries its SERIALIZABLE transaction — or, if it\n * had not read the draft yet, inserts a fresh draft holding only its own patch.\n * Either way it does not write a stale copy back over the published one. The editor also flushes and awaits its own in-flight saves\n * before it calls this, so the lock is the backstop, not the mechanism.\n *\n * Path-index and write notifications fire after COMMIT, not inside it —\n * `AdminClient.afterCommit` defers them on a tracked transaction.\n */\nexport async function publishDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!(await getDraftsClient(ctx))) {\n throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n }\n\n // Previous state for the version delta — locale-merged, so the delta compares\n // against what this locale's readers actually saw. Read BEFORE the\n // transaction: it is the committed state the publish replaces.\n const previousState: InferEntityDTO<F> | null = ctx.versionableOptions\n ? await ctx.admin.findById(entityId, localeFindOptions(locale))\n : null\n\n return ctx.db.transaction(async (tx) => {\n // Every table client below binds to `tx` through this context — drafts,\n // locale status, versions and the `_version` column write.\n const txCtx: ContentContext<F> = { ...ctx, db: tx }\n const draftsClient = await getDraftsClient(txCtx)\n if (!draftsClient) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n const [claimed] = await draftsClient.deleteMany({ entityId, locale })\n if (!claimed) {\n throw new DraftNotFoundError(entityId, locale)\n }\n\n // `?? {}` because the column is `jsonb NOT NULL`, and that forbids SQL NULL\n // while permitting the JSON literal `null` — which deserializes to JS `null`.\n // A bare cast then made `draftData.status` a TypeError, so a malformed row\n // 500'd the publish route instead of taking the ordinary \"nothing declared,\n // publish the row as-is\" path below. Nothing in this package writes such a\n // row; a seed, a migration or a hand-edit can.\n const claimedData = (claimed.data ?? {}) as Record<string, unknown>\n // `publishedAt` is Publish's to set, like `status` below: a staged value\n // would let whoever wrote the draft back- or forward-date the publication.\n // publishable()'s hook fills it when the UPDATE carries none — which, with\n // the staged value dropped, is every publish: it records the LATEST\n // publication, not the first.\n const { publishedAt: _stagedPublishedAt, ...draftData } = claimedData\n // \"Publish\" means MAKE LIVE, always — the status is not the draft's to\n // decide. Block-editor drafts carry no `status` at all, and before D019 a\n // form-saved draft could carry `status: 'draft'`, which then won: Publish\n // wrote the content and left the document unpublished, so the same button\n // meant \"publish\" on one surface and \"stage\" on another. The publishable()\n // behavior auto-sets `publishedAt` on the transition. The publish-permission\n // gate lives in the admin route, not here.\n const updateData = projectToEntityFields(ctx.entity, { ...draftData, status: 'published' })\n const result = await applyLocaleRoutedUpdate(txCtx, entityId, updateData, locale, tx)\n\n // F009 — promote the page's chrome overrides.\n //\n // `projectToEntityFields` drops every `internal: true` field, deliberately, so\n // `_overrides` cannot ride the generic promotion. That guard is NOT relaxed:\n // this is one named field, written through the trusted `updateInternal` path\n // that `with-overrides.ts` documents as \"the only path that can mutate it\".\n // Before F009 that call site did not exist, so an override survived in the\n // draft buffer and never reached the column — the op committed, the canvas\n // updated, and a reload brought the hidden header back.\n //\n // AFTER the main update, so the override never lands against content that\n // failed to — and inside the same transaction, so neither lands alone.\n // `_overrides` is not translatable, so it belongs on the base row for every\n // locale.\n const overrides = readOverridesForPublish(ctx.entity, draftData)\n if (overrides !== undefined) {\n await ctx.admin.updateInternal(entityId, { _overrides: overrides }, { tx })\n }\n\n if (ctx.versionableOptions) {\n await createVersionSnapshot(txCtx, entityId, result, previousState, locale, user)\n }\n\n return result\n })\n}\n\n/**\n * The page's override map from a draft payload, when the entity declares the\n * field and the draft carries one.\n *\n * Returns `undefined` — never `{}` — so an ordinary content publish does not\n * touch the column. Shape-guarded because a draft payload is stored jsonb: an\n * array or a scalar is malformed data, not an override map. ← F009\n */\nfunction readOverridesForPublish(\n entity: { allFields: Record<string, unknown>; behaviors?: readonly { name: string }[] },\n draftData: Record<string, unknown>,\n): Record<string, unknown> | undefined {\n // The BEHAVIOR, not just a field with the right name — a draft payload is\n // stored jsonb and can be crafted or legacy, so this must not become a way\n // to promote arbitrary JSON into an unrelated internal field on an entity\n // that never opted in. Requiring the behavior also means `beforeUpdate` runs\n // `validateOverridesMap` on the way through `updateInternal`.\n if (!(entity.behaviors ?? []).some((b) => b.name === 'withOverrides')) return undefined\n const field = entity.allFields._overrides as { internal?: boolean } | undefined\n if (field === undefined || field.internal !== true) return undefined\n const value = draftData._overrides\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined\n return value as Record<string, unknown>\n}\n\n/**\n * Quick existence check for list badges — does a draft exist?\n */\nexport async function hasDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return false\n return client.exists({ entityId, locale })\n}\n\n/**\n * Count drafts for this entity type, optionally filtered by locale.\n * Single indexed COUNT — used by dashboard widgets.\n */\nexport async function countDrafts(\n ctx: ContentContext,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<number> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return 0\n return client.count({ locale })\n}\n\n/**\n * Check draft existence for multiple entities in one query. Returns\n * a set of entity IDs that have a draft for `locale`.\n */\nexport async function hasDraftBatch(\n ctx: ContentContext,\n entityIds: string[],\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<Set<string>> {\n validateLocale(locale)\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n const client = await getDraftsClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n return result\n}\n\n/**\n * Compare the current draft with the live entity. Returns field-level\n * diffs between the published data and the draft (only for fields\n * present in the draft — missing fields are NOT \"changed to empty\").\n */\nexport async function compareDraftWithCurrent<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n current: InferEntityDTO<F>\n draft: DraftEntry<InferUpdateInput<F>>\n} | null> {\n validateLocale(locale)\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) return null\n\n // Fetch the live entity with the same locale context as the draft.\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions =\n locale === DEFAULT_LOCALE_SENTINEL ? { defaultLocale } : { locale, defaultLocale }\n const current = await ctx.admin.findById(entityId, findOptions)\n if (!current) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const currentRecord = current as Record<string, unknown>\n const draftRecord = draft.data as Record<string, unknown>\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draftRecord)) {\n currentSubset[key] = currentRecord[key]\n }\n const diffs = diffFields(currentSubset, draftRecord, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Per-locale publish status — independent draft/published state for\n * each locale of a publishable + translatable entity.\n *\n * Capability: requires `publishable() + at least one translatable\n * field` (the `_locale_status` table is registered only for that\n * combination). Reads silently return falsy on entities without it;\n * `setLocaleStatus` throws `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { EntityCapabilityError } from './errors.js'\nimport { type ContentContext, getLocaleStatusClient } from './internal/context.js'\nimport { upsertLocaleStatusRow } from './locale-status-slot.js'\nimport { validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { LocalePublishStatus } from './types.js'\n\n/**\n * Get per-locale publish status overrides for an entity.\n * Missing locale = inherits the base entity status (no row written).\n *\n * **Not permission-gated, deliberately — see {@link getLocaleStatusBatch}.**\n */\nexport async function getLocaleStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<LocalePublishStatus[]> {\n const map = await getLocaleStatusBatch(ctx, [entityId])\n const entry = map.get(entityId)\n if (!entry) return []\n return Object.values(entry)\n}\n\n/**\n * Set per-locale publish status. Upserts into the locale_status\n * table — except when `locale === defaultLocale`, which updates the\n * base entity status column directly (the per-locale table only\n * stores overrides for non-default locales).\n *\n * **Both branches are publish-gated by the entity layer (D041).** The default\n * branch inherits it from `AdminClient.update`; the non-default branch routes\n * through `AdminClient.updateForLocale`, which asserts the SAME\n * `assertPublishAuthority` from the caller's payload before it hands the write\n * to the locale-status writer. This is deliberately a delegation and not a\n * second predicate in this package: `<entity>_locale_status` is the table the\n * public read predicate (`COALESCE(locale_status.status, main.status)`) resolves\n * against, so a direct `TableClient.upsert` here — which is what this function\n * used to do — is a fourth publish door, reachable from any plugin/route/job\n * holding a `ContentClient` (a PUBLIC subpath export) with no `publish` grant\n * ever consulted.\n */\nexport async function setLocaleStatus<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n validateLocale(locale)\n const config = getContentConfig()\n\n // Default locale → update base entity status column.\n if (locale === config.defaultLocale) {\n const data: Record<string, unknown> = { status }\n if (status === 'published') data.publishedAt = new Date()\n await ctx.admin.update(entityId, data as InferUpdateInput<F>)\n return\n }\n\n const client = await getLocaleStatusClient(ctx)\n if (!client) {\n // Per-locale status only exists for publishable + translatable\n // entities. Calling on a non-translatable publishable (where the\n // only valid locale is the default) is a programmer error.\n // Checked BEFORE delegating: `updateForLocale` would otherwise fall back to\n // the base `status` column for a missing writer, silently publishing the\n // default locale instead of refusing.\n throw new EntityCapabilityError(ctx.entity.name, 'localeStatus')\n }\n\n // The writer we hand down is the RAW upsert, not this function — the\n // delegation terminates here rather than re-entering `updateForLocale`.\n //\n // Routing through `updateForLocale` rather than calling the upsert directly\n // is what closes the publish door: this branch used to write\n // `<entity>_locale_status` with no permission check at all, so any holder of\n // this public export could make a translation publicly live without\n // `publish`. `updateForLocale` asserts publish authority at the operation\n // entry (D041), so the gate is inherited rather than duplicated here.\n //\n // Widened through `Record<string, unknown>` for the same reason the\n // default-locale branch above does it: `InferUpdateInput<F>` is keyed on the\n // entity's own field map, which a generic `F` cannot be proven to overlap.\n const localeData: Record<string, unknown> = { status }\n await ctx.admin.updateForLocale(entityId, localeData as InferUpdateInput<F>, locale, {\n defaultLocale: config.defaultLocale,\n localeStatusWriter: (id, loc, s) => upsertLocaleStatusRow(client, id, loc, s),\n })\n}\n\n/**\n * Get per-locale publish status for multiple entities in one query.\n *\n * ## Why this read is NOT permission-gated (and what would gate it)\n *\n * It returns publish-state METADATA only — `{ locale, status, publishedAt }` —\n * never field data, so it is not a content-disclosure door the way the write\n * side above is a publish door. What it does expose is an existence oracle for\n * unpublished TRANSLATIONS: a principal without `view_unpublished` learns that\n * `et` is a draft, which `QueryClient` would have hidden. The correct predicate\n * is therefore `view_unpublished` (D017), NOT `publish`.\n *\n * A CodeRabbit pass proposed gating it on exactly that predicate. Deliberately\n * declined, on the strength of **F063**: `view_unpublished` governs `QueryClient`\n * reads — the PUBLIC delivery surface — and is not a disclosure boundary over\n * the admin API, where `AdminClient` shows editors their own drafts by design.\n * `ContentClient` is an in-process server-side API whose consumers are plugins,\n * routes and jobs on the admin side, so gating it here would extend the control\n * onto the surface F063 explicitly recommended NOT extending it to. The concrete\n * cost would be an editor lacking `view_unpublished` no longer able to see that\n * their OWN translation is still a draft.\n *\n * The asymmetry with the write side above is real, not convenient:\n * `setLocaleStatus` MINTS public visibility, so it is gated regardless of which\n * surface calls it. This read hands publish-state metadata to a caller who\n * already holds `view` on the row — they needed its id to ask.\n *\n * It is not applied here because the predicate (`mayViewUnpublishedEntity`) is\n * module-private to `@murumets-ee/entity` — re-deriving it in this package\n * would create exactly the second, drifting copy of a security predicate that\n * D041 exists to eliminate, and there is no read-path equivalent of\n * `updateForLocale` to delegate to. The one in-repo caller\n * (`admin-ui`'s versions route) is already gated by `checkPermission`. Closing\n * this properly means exporting the predicate from `@murumets-ee/entity` and\n * dropping non-`published` rows for principals without it — tracked as the\n * read-side sibling of F060.\n */\nexport async function getLocaleStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, Record<string, LocalePublishStatus>>> {\n const result = new Map<string, Record<string, LocalePublishStatus>>()\n if (entityIds.length === 0) return result\n const client = await getLocaleStatusClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n let entry = result.get(eid)\n if (!entry) {\n entry = {}\n result.set(eid, entry)\n }\n entry[locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n return result\n}\n","/**\n * Translation status reporting — per-locale completion of translatable\n * fields for one entity, batches of entities, or the whole entity type.\n *\n * Capability: requires the entity to have at least one translatable\n * field. All methods return falsy / empty when the entity has no\n * translatable fields (read-only and defensive).\n */\n\nimport 'server-only'\n\nimport { getContext, requireSiteFor } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { buildPartitionCondition, type FieldConfig } from '@murumets-ee/entity'\nimport { and, inArray } from 'drizzle-orm'\nimport { InvalidLocaleError } from './errors.js'\nimport type { ContentContext } from './internal/context.js'\nimport { getContentConfig } from './plugin.js'\nimport type { TranslationReport, TranslationStatus } from './types.js'\n\n/**\n * The locales a status report covers and the default it skips: the request's\n * — which the admin narrows to its SITE's locales and default (plan `multisite`\n * phase 03) — else the configured content locales.\n */\nfunction statusLocales(): { locales: { code: string }[]; defaultLocale: string } {\n const config = getContentConfig()\n const ctx = getContext()\n const codes = ctx?.availableLocales ?? config.locales.map((l) => l.code)\n return {\n locales: codes.map((code) => ({ code })),\n defaultLocale: ctx?.defaultLocale ?? config.defaultLocale,\n }\n}\n\n/**\n * For one entity, compute per-locale completion status across every\n * translatable field. Default-locale rows are skipped.\n */\nfunction computeStatusForEntity(\n translatableFields: string[],\n translatedByLocale: Map<string, Record<string, unknown>> | undefined,\n locales: { code: string }[],\n defaultLocale: string,\n): TranslationStatus {\n const status: TranslationStatus = {}\n for (const locale of locales) {\n if (locale.code === defaultLocale) continue\n const translated = translatedByLocale?.get(locale.code) ?? {}\n const missing: string[] = []\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatableFields.length - missing.length,\n missing,\n }\n }\n return status\n}\n\n/**\n * Get translation status for an entity — per-locale completion report.\n */\nexport async function getTranslationStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<TranslationStatus> {\n // Single-entity is the batch path with one ID — keeps the two\n // codepaths from drifting.\n const map = await getTranslationStatusBatch(ctx, [entityId])\n return map.get(entityId) ?? {}\n}\n\n/**\n * Get translation status for multiple entities in a single query.\n * O(1) DB roundtrips regardless of `entityIds.length`.\n */\nexport async function getTranslationStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const { locales, defaultLocale } = statusLocales()\n\n const translatableFields = Object.entries(ctx.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK\n // refs); accessed via schemaRegistry + direct Drizzle here.\n const translationTable = schemaRegistry.get(`${ctx.entity.name}_translations`)\n if (!translationTable) return result\n\n // A site-bound entity's translation rows are confined to the request's\n // site (multisite D003): ids alone are no boundary — a caller holding\n // another site's id must learn nothing about its translations.\n const site = await requireSiteFor(ctx.entity)\n const byIds = inArray(translationTable.entityId, entityIds)\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(\n site !== undefined\n ? and(byIds, buildPartitionCondition(translationTable, { site }, ctx.entity.name))\n : byIds,\n )\n\n // Group: entityId → locale → { fieldName: value }\n const translationsByEntity = new Map<string, Map<string, Record<string, unknown>>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const eid = row.entityId as string\n const locale = row.locale as string\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n let entry = translationsByEntity.get(eid)\n if (!entry) {\n entry = new Map()\n translationsByEntity.set(eid, entry)\n }\n entry.set(locale, fieldValues)\n }\n\n for (const entityId of entityIds) {\n result.set(\n entityId,\n computeStatusForEntity(\n translatableFields,\n translationsByEntity.get(entityId),\n locales,\n defaultLocale,\n ),\n )\n }\n\n return result\n}\n\n/**\n * Roll up translation status across every entity of this type.\n *\n * Defaults to the first non-default configured locale; pass\n * `options.locale` to target a specific one. Returns counts of\n * complete / partial / missing entities for that locale.\n */\nexport async function getTranslationReport(\n ctx: ContentContext,\n options?: { locale?: string },\n): Promise<TranslationReport> {\n const { locales, defaultLocale } = statusLocales()\n const targetLocale = options?.locale ?? locales.find((l) => l.code !== defaultLocale)?.code\n if (!targetLocale) return { total: 0, complete: 0, partial: 0, missing: 0 }\n // A locale the site does not serve has no report on that site.\n if (!locales.some((l) => l.code === targetLocale)) {\n throw new InvalidLocaleError(\n targetLocale,\n locales.map((l) => l.code),\n )\n }\n\n const entities = await ctx.admin.findMany()\n if (entities.length === 0) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n // Single batched query covers every entity at once.\n const statusMap = await getTranslationStatusBatch(\n ctx,\n entities.map((e) => e.id),\n )\n\n let complete = 0\n let partial = 0\n let missing = 0\n for (const entity of entities) {\n const status = statusMap.get(entity.id)\n const localeStatus = status?.[targetLocale]\n if (!localeStatus || localeStatus.translated === 0) missing++\n else if (localeStatus.complete) complete++\n else partial++\n }\n\n return { total: entities.length, complete, partial, missing }\n}\n","/**\n * ContentClient — thin shell over per-feature modules.\n *\n * Wraps AdminClient with version history (`./versions.ts`), draft\n * overlays (`./drafts.ts`), translation status reporting\n * (`./translation-status.ts`), per-locale publish status\n * (`./locale-status.ts`), and block-layout operations (`./layout.ts`).\n *\n * The class is generic over the entity's `AllFields` map: when\n * constructed with a typed entity, every method's input/output is\n * inferred end-to-end. When constructed with a runtime `Entity`\n * (admin-ui's heterogeneous entity loops), the generic defaults to\n * `Record<string, FieldConfig>` and methods accept/return the loose\n * shape — same behavior as the pre-generic version.\n *\n * Usage:\n * import { createContentClient } from '@murumets-ee/content/client'\n * const pages = createContentClient(Page)\n * await pages.create({ title: 'Hello', layout: [...] })\n */\n\nimport 'server-only'\n\nimport { getApp, type Logger } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type {\n AdminClient,\n FindManyOptions,\n InferCreateInput,\n InferEntityDTO,\n InferUpdateInput,\n} from '@murumets-ee/entity/admin'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport {\n compareDraftWithCurrent,\n countDrafts,\n deleteDraft,\n getDraft,\n hasDraft,\n hasDraftBatch,\n mergeDraft,\n publishDraft,\n saveDraft,\n} from './drafts.js'\nimport { type ContentContext, createContentContext } from './internal/context.js'\nimport { calculateDelta } from './internal/projection.js'\nimport { getLocaleStatus, getLocaleStatusBatch, setLocaleStatus } from './locale-status.js'\nimport {\n getTranslationReport,\n getTranslationStatus,\n getTranslationStatusBatch,\n} from './translation-status.js'\nimport {\n compareVersions,\n createVersionSnapshot,\n getVersion,\n getVersions,\n pruneVersions,\n restoreVersion,\n saveVersionSnapshot,\n writeVersionColumn,\n} from './versions.js'\n\nexport interface ContentClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n}\n\nexport class ContentClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private ctx: ContentContext<AllFields>\n\n constructor(config: ContentClientConfig<AllFields>) {\n const versionableOptions = getVersionableOptions(config.entity.behaviors)\n this.ctx = createContentContext(config, versionableOptions)\n }\n\n // -----------------------------------------------------------------\n // CRUD (delegated to AdminClient + version snapshots)\n // -----------------------------------------------------------------\n\n async create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n const result = await this.ctx.admin.create(data)\n if (this.ctx.versionableOptions) {\n await saveVersionSnapshot(this.ctx, result.id, result, null, false)\n }\n return result\n }\n\n findById(id: string, options?: { locale?: string }): Promise<InferEntityDTO<AllFields> | null> {\n return this.ctx.admin.findById(id, options)\n }\n\n findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n return this.ctx.admin.findMany(options)\n }\n\n /**\n * Base-row update + version snapshot. Subject to AdminClient's\n * cross-locale write guard (SD021): under a non-default request-context\n * locale, translatable fields throw `CrossLocaleWriteError` — that's\n * enforcement, not a gap. Locale-belonging writes go through the draft\n * flow (`saveDraft` → `publishDraft`) or `admin.updateForLocale` directly.\n */\n async update(id: string, data: InferUpdateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n let previousState: InferEntityDTO<AllFields> | null = null\n if (this.ctx.versionableOptions) {\n previousState = await this.ctx.admin.findById(id)\n }\n\n const result = await this.ctx.admin.update(id, data)\n\n if (this.ctx.versionableOptions && previousState) {\n const delta = calculateDelta(previousState, result)\n const version = await saveVersionSnapshot(this.ctx, id, result, delta, false)\n if (version !== undefined) {\n await writeVersionColumn(this.ctx, id, version)\n await pruneVersions(this.ctx, id)\n return { ...result, _version: version } as InferEntityDTO<AllFields>\n }\n await pruneVersions(this.ctx, id)\n }\n\n return result\n }\n\n delete(id: string): Promise<unknown> {\n return this.ctx.admin.delete(id)\n }\n\n // -----------------------------------------------------------------\n // Translation delegation\n // -----------------------------------------------------------------\n\n saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n return this.ctx.admin.saveTranslation(entityId, locale, translations)\n }\n\n deleteTranslation(entityId: string, locale?: string) {\n return this.ctx.admin.deleteTranslation(entityId, locale)\n }\n\n // -----------------------------------------------------------------\n // Version history\n // -----------------------------------------------------------------\n\n getVersions(entityId: string, options?: { limit?: number; offset?: number; locale?: string }) {\n return getVersions(this.ctx, entityId, options)\n }\n\n getVersion(entityId: string, version: number, locale?: string) {\n return getVersion(this.ctx, entityId, version, locale)\n }\n\n compareVersions(entityId: string, v1: number, v2: number, locale?: string) {\n return compareVersions(this.ctx, entityId, v1, v2, locale)\n }\n\n restoreVersion(entityId: string, version: number, locale?: string) {\n return restoreVersion(this.ctx, entityId, version, locale)\n }\n\n pruneVersions(entityId: string, locale?: string) {\n return pruneVersions(this.ctx, entityId, locale)\n }\n\n createVersionSnapshot(\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return createVersionSnapshot(this.ctx, entityId, currentData, previousData, locale, user)\n }\n\n // -----------------------------------------------------------------\n // Translation status\n // -----------------------------------------------------------------\n\n getTranslationStatus(entityId: string) {\n return getTranslationStatus(this.ctx, entityId)\n }\n\n getTranslationStatusBatch(entityIds: string[]) {\n return getTranslationStatusBatch(this.ctx, entityIds)\n }\n\n getTranslationReport(options?: { locale?: string }) {\n return getTranslationReport(this.ctx, options)\n }\n\n // -----------------------------------------------------------------\n // Per-locale publish status\n // -----------------------------------------------------------------\n\n getLocaleStatus(entityId: string) {\n return getLocaleStatus(this.ctx, entityId)\n }\n\n setLocaleStatus(entityId: string, locale: string, status: 'draft' | 'published') {\n return setLocaleStatus(this.ctx, entityId, locale, status)\n }\n\n getLocaleStatusBatch(entityIds: string[]) {\n return getLocaleStatusBatch(this.ctx, entityIds)\n }\n\n // -----------------------------------------------------------------\n // Drafts\n // -----------------------------------------------------------------\n\n saveDraft(\n entityId: string,\n data: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return saveDraft(this.ctx, entityId, data, locale, user)\n }\n\n mergeDraft(\n entityId: string,\n patch: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return mergeDraft(this.ctx, entityId, patch, locale, user)\n }\n\n getDraft(entityId: string, locale?: string) {\n return getDraft(this.ctx, entityId, locale)\n }\n\n deleteDraft(entityId: string, locale?: string) {\n return deleteDraft(this.ctx, entityId, locale)\n }\n\n publishDraft(entityId: string, locale?: string, user?: { id: string; name?: string }) {\n return publishDraft(this.ctx, entityId, locale, user)\n }\n\n hasDraft(entityId: string, locale?: string) {\n return hasDraft(this.ctx, entityId, locale)\n }\n\n countDrafts(locale?: string) {\n return countDrafts(this.ctx, locale)\n }\n\n hasDraftBatch(entityIds: string[], locale?: string) {\n return hasDraftBatch(this.ctx, entityIds, locale)\n }\n\n compareDraftWithCurrent(entityId: string, locale?: string) {\n return compareDraftWithCurrent(this.ctx, entityId, locale)\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity. Must be called\n * after `createApp()`. Generic flows from the entity definition:\n * `createContentClient(Article)` returns a `ContentClient<typeof\n * Article['allFields']>` typed end-to-end.\n */\nexport function createContentClient<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n): ContentClient<AllFields> {\n const admin = createAdminClient(entity)\n return new ContentClient<AllFields>({\n admin,\n entity,\n logger: getApp().logger.child({ content: entity.name }),\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../src/drafts.ts","../src/locale-status.ts","../src/translation-status.ts","../src/client.ts"],"sourcesContent":["/**\n * Draft-overlay operations.\n *\n * Capability: requires the entity to declare `publishable()`. Reads\n * (`getDraft`, `hasDraft`, `countDrafts`, `hasDraftBatch`) silently\n * return falsy when the entity doesn't support drafts; writes\n * (`saveDraft`, `mergeDraft`, `publishDraft`) throw `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport { blockTreeFieldsOf, type FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { DraftNotFoundError, EntityCapabilityError } from './errors.js'\nimport {\n applyLocaleRoutedUpdate,\n localeFindOptions,\n} from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getDraftsClient } from './internal/context.js'\nimport { projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { DraftsTable } from './schema/drafts.js'\nimport type { DraftEntry } from './types.js'\nimport { createVersionSnapshot } from './versions.js'\n\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\n\nfunction toDraftEntry(row: DraftRow): DraftEntry {\n return {\n id: row.id,\n entityId: row.entityId,\n locale: row.locale,\n data: row.data,\n createdBy: row.createdBy,\n createdByName: row.createdByName ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }\n}\n\n/**\n * Save (upsert) a draft for an entity+locale. One draft per\n * entity+locale — overwrites any existing draft.\n *\n * `data` is projected through the entity's declared field whitelist\n * BEFORE it is stored (same `projectToEntityFields` call `publishDraft`\n * makes on the way out). Storing the caller's JSON verbatim let a draft\n * carry keys the entity never declared, identity/audit columns and\n * `internal: true` fields; publishing filtered them, but `getDraft` and\n * `compareDraftWithCurrent` echoed them straight back to the admin UI,\n * and the drafts table accumulated arbitrary caller-controlled JSON\n * (`plan/admin-api-hardening` F002). Validating at the write closes the\n * asymmetry — the stored draft is now always a subset of what the entity\n * can actually hold.\n */\nexport async function saveDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n data: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>>> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const projected = projectToEntityFields(ctx.entity, data as Record<string, unknown>)\n\n // `projectToEntityFields` is a field-NAME whitelist — it never inspects a\n // value, so a `field.blockTree()` column in a draft took arbitrary JSON, and\n // `editor-state` hands the stored draft back as the CANVAS BASE STATE\n // (editor F002). Publishing is already gated by the entity write path, but\n // waiting until publish means the editor loads a tree it cannot represent.\n // Same validator, same judgement — asked here because the draft blob carries\n // no Zod schema of its own.\n ctx.admin.assertBlockTreesValid(projected)\n // …and the STRICT judgement for a token-derived caller (agent-access D014):\n // a draft is published later by whoever presses Publish, so a tree a machine\n // could not store directly must not be parked here for a person to promote.\n // F116, same reasoning — and this is precisely the write path the finding\n // was filed against: dava's home-page DRAFT already holds a mismatched\n // media reference. Grandfathering against the draft ALREADY STORED (rather\n // than validating strictly) is what keeps that draft's next autosave\n // working instead of turning \"a broken image\" into \"a draft that can never\n // be saved again\".\n //\n // The lookup is skipped entirely when the PROJECTED payload carries no\n // block-tree field at all — `assertMediaKindsValid` would no-op anyway\n // (same optimization as `saveTranslation`'s), and most entities with\n // drafts have no block-tree field to begin with.\n const hasBlockTreeValue = blockTreeFieldsOf(ctx.entity).some(({ name }) =>\n Object.hasOwn(projected, name),\n )\n const existing = hasBlockTreeValue ? await client.findOne({ entityId, locale }) : null\n // Grandfathered against the draft already stored, like the media-kind check\n // below: a tree that was there before this save is not what this save adds.\n await ctx.admin.assertBlockTreesStrictForCaller(\n projected,\n () => existing?.data as Record<string, unknown> | undefined,\n )\n await ctx.admin.assertMediaKindsValid(\n projected,\n existing?.data as Record<string, unknown> | undefined,\n )\n\n const now = new Date()\n const createdBy = user?.id ?? ''\n const createdByName = user?.name ?? null\n\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: projected,\n createdBy,\n createdByName,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only update the mutable fields — preserve createdBy/createdAt.\n set: { data: projected, updatedAt: now },\n },\n )\n\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * The keys that move a document's canonical URL in the patterns the toolkit\n * ships (`[slug]`, `[parent.path]`) plus the slug mode that decides whether a\n * clash is refused or auto-suffixed. A merge naming one of them, on a ROUTABLE\n * entity, runs the path-conflict check (see `mergeDraft`); any other merge\n * skips its lookups — the staged draft, the row, and `toolkit_paths` (twice\n * under a parent).\n *\n * A pattern built from another field (`/[category]/[slug]`) is not covered:\n * staging only `category` is refused at Publish rather than here.\n */\nconst PATH_KEYS = ['slug', 'slugMode', 'parent'] as const\n\n/**\n * Stage a PARTIAL change: merge `patch` into the entity+locale draft, creating\n * the draft if there is none. Keys not in `patch` are left exactly as the draft\n * (or, absent a draft key, the live row) has them.\n *\n * This is the write every editing surface uses since editor D019 — \"every edit\n * on every surface saves to the document's one shared draft\". `saveDraft`\n * REPLACES the blob, which is right for a surface that owns the whole document\n * (the menu editor) and wrong for one that owns a few fields of it: page\n * settings saving `title` through `saveDraft` would erase the block edits the\n * canvas staged a second earlier. A draft is therefore a patch over the row, and\n * every reader that shows editable values reads row ⊕ draft.\n *\n * Judged like the entity PATCH it replaces, BEFORE it is stored:\n * - projected through the field whitelist (`internal`, undeclared keys dropped)\n * - `assertBlockTreesValid` + `assertMediaKindsValid` (grandfathered against the\n * stored draft, same as `saveDraft`)\n * - `assertUpdateValid` — the entity's own update schema. `saveDraft` never ran\n * it, which was harmless while the form's Publish still PATCHed; moving a\n * surface into the draft must not move its refusal to Publish, where the\n * author is no longer looking at the field.\n * - `assertPathAvailable` — the path-conflict guard, as a dry run, when the\n * merge moves the URL (see {@link PATH_KEYS}) on a routable entity (F150).\n *\n * 🔴 ONE STATEMENT, not read-modify-write. The merge is `data || patch` inside\n * `ON CONFLICT DO UPDATE` (`TableClient.upsert`'s `mergeJsonb`), so it cannot\n * lose a concurrent write: a read-then-upsert that interleaved with op-commit's\n * or a bound field's stage would write back the draft as it was read — silently\n * reverting the other writer's change. `writeDraft` merges the same way, so\n * neither can erase the other. Top-level `||`, a SHALLOW merge.\n *\n * Resolves `null` when the projected patch is EMPTY and no draft exists: nothing\n * the entity declares was sent, so nothing is staged and no empty draft row is\n * created.\n *\n * ⚠️ The rest of the write path's hooks — `pathParent()`'s cycle check, the\n * partial-unique slug index, `sluggable()`'s regeneration — still run only at\n * Publish, inside its transaction, where a refusal stops the whole publish. The\n * path check here answers \"as of now\"; Publish runs the real guard again.\n */\nexport async function mergeDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n patch: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const projected = projectToEntityFields(ctx.entity, patch as Record<string, unknown>)\n // Nothing the entity declares: stage nothing. Upserting `{}` would CREATE an\n // empty draft, and the document would then read \"Draft saved · not live\n // yet\" about a change that does not exist.\n if (Object.keys(projected).length === 0) {\n const current = await client.findOne({ entityId, locale })\n return current ? (toDraftEntry(current) as DraftEntry<InferUpdateInput<F>>) : null\n }\n ctx.admin.assertBlockTreesValid(projected)\n ctx.admin.assertUpdateValid(projected)\n const hasBlockTreeValue = blockTreeFieldsOf(ctx.entity).some(({ name }) =>\n Object.hasOwn(projected, name),\n )\n const touchesPath =\n ctx.entity.frontend?.path !== undefined &&\n PATH_KEYS.some((key) => Object.hasOwn(projected, key))\n const existing =\n hasBlockTreeValue || touchesPath ? await client.findOne({ entityId, locale }) : null\n // The STRICT judgement for a token-derived caller (agent-access D014), with\n // the stored draft to grandfather against — see `saveDraft`.\n await ctx.admin.assertBlockTreesStrictForCaller(\n projected,\n () => existing?.data as Record<string, unknown> | undefined,\n )\n await ctx.admin.assertMediaKindsValid(\n projected,\n existing?.data as Record<string, unknown> | undefined,\n )\n // A staged slug that another document's URL already holds is refused NOW,\n // beside the field the author typed it in — not first at Publish (editor\n // PR60, F150). Checked against what the draft will hold after this merge,\n // since an earlier stage may have set `slugMode` or `parent`.\n if (touchesPath) {\n const staged = {\n ...((existing?.data as Record<string, unknown> | undefined) ?? {}),\n ...projected,\n }\n if (locale === DEFAULT_LOCALE_SENTINEL) {\n await ctx.admin.assertPathAvailable(entityId, staged)\n } else {\n // Checked the way Publish writes it: `updateForLocale` sends a locale's\n // TRANSLATABLE fields through that locale's guard and the rest (a\n // `parent`, a non-translatable slug) through `update()`, whose guard\n // checks the default locale. One guard over everything would test a\n // shared `parent` against the wrong locale's URLs.\n const fields = ctx.entity.allFields as Record<string, { translatable?: boolean } | undefined>\n const translatable: Record<string, unknown> = {}\n const shared: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(staged)) {\n if (fields[key]?.translatable === true) translatable[key] = value\n else shared[key] = value\n }\n const moves = (part: Record<string, unknown>) =>\n PATH_KEYS.some((key) => Object.hasOwn(part, key) && Object.hasOwn(projected, key))\n if (moves(translatable)) await ctx.admin.assertPathAvailable(entityId, translatable, locale)\n if (moves(shared)) await ctx.admin.assertPathAvailable(entityId, shared)\n }\n }\n\n const now = new Date()\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: projected,\n createdBy: user?.id ?? '',\n createdByName: user?.name ?? null,\n createdAt: now,\n updatedAt: now,\n },\n { target: ['entityId', 'locale'], set: { updatedAt: now }, mergeJsonb: ['data'] },\n )\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * Get the draft for an entity+locale, or null if none exists (or the\n * entity doesn't support drafts).\n */\nexport async function getDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, locale })\n return row ? (toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>) : null\n}\n\n/**\n * Delete (discard) a draft for an entity+locale. Returns whether\n * anything was deleted; safely no-ops on entities without drafts.\n */\nexport async function deleteDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return false\n const deleted = await client.deleteMany({ entityId, locale })\n return deleted.length > 0\n}\n\n/**\n * Publish a draft: make THIS document's staged changes live.\n *\n * \"Publish\" means one thing on every surface (editor D019): the draft's fields\n * are written to the row for the draft's locale AND the row is put live. The\n * draft is then gone and, if versionable, a version snapshot records what went\n * live.\n *\n * Locale routing mirrors the admin PATCH handler (and the taxonomy fix\n * in 10a7cc2b): a default-locale draft writes the base row; a\n * non-default-locale draft routes translatable fields to\n * `saveTranslation` (→ `{entity}_translations`), `status` to the\n * per-locale status table, and only non-translatable fields (which have\n * no per-locale home) to the base row. Without this split, publishing\n * an `et` draft overwrote the default-locale base row wholesale.\n *\n * ## ONE TRANSACTION (editor PR60, R055 §4)\n *\n * The row write, the locale status, `_overrides`, the draft delete and the\n * version snapshot commit together or not at all. They used to be five separate\n * statements, so a failure part-way left a document whose content was live and\n * whose draft still claimed to be pending — or, worse, whose draft was gone while\n * the row write had not landed.\n *\n * The draft is CLAIMED first, by deleting it and publishing what the delete\n * returned. The delete takes the row lock, so an op-commit that is mid-flight on\n * the same draft either committed before (and its edit is in what we publish) or\n * blocks until we commit and then retries its SERIALIZABLE transaction — or, if it\n * had not read the draft yet, inserts a fresh draft holding only its own patch.\n * Either way it does not write a stale copy back over the published one. The editor also flushes and awaits its own in-flight saves\n * before it calls this, so the lock is the backstop, not the mechanism.\n *\n * Path-index and write notifications fire after COMMIT, not inside it —\n * `AdminClient.afterCommit` defers them on a tracked transaction.\n */\nexport async function publishDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!(await getDraftsClient(ctx))) {\n throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n }\n\n // Previous state for the version delta — locale-merged, so the delta compares\n // against what this locale's readers actually saw. Read BEFORE the\n // transaction: it is the committed state the publish replaces.\n const previousState: InferEntityDTO<F> | null = ctx.versionableOptions\n ? await ctx.admin.findById(entityId, localeFindOptions(locale))\n : null\n\n return ctx.db.transaction(async (tx) => {\n // Every table client below binds to `tx` through this context — drafts,\n // locale status, versions and the `_version` column write.\n const txCtx: ContentContext<F> = { ...ctx, db: tx }\n const draftsClient = await getDraftsClient(txCtx)\n if (!draftsClient) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n const [claimed] = await draftsClient.deleteMany({ entityId, locale })\n if (!claimed) {\n throw new DraftNotFoundError(entityId, locale)\n }\n\n // `?? {}` because the column is `jsonb NOT NULL`, and that forbids SQL NULL\n // while permitting the JSON literal `null` — which deserializes to JS `null`.\n // A bare cast then made `draftData.status` a TypeError, so a malformed row\n // 500'd the publish route instead of taking the ordinary \"nothing declared,\n // publish the row as-is\" path below. Nothing in this package writes such a\n // row; a seed, a migration or a hand-edit can.\n const claimedData = (claimed.data ?? {}) as Record<string, unknown>\n // `publishedAt` is Publish's to set, like `status` below: a staged value\n // would let whoever wrote the draft back- or forward-date the publication.\n // publishable()'s hook fills it when the UPDATE carries none — which, with\n // the staged value dropped, is every publish: it records the LATEST\n // publication, not the first.\n const { publishedAt: _stagedPublishedAt, ...draftData } = claimedData\n // \"Publish\" means MAKE LIVE, always — the status is not the draft's to\n // decide. Block-editor drafts carry no `status` at all, and before D019 a\n // form-saved draft could carry `status: 'draft'`, which then won: Publish\n // wrote the content and left the document unpublished, so the same button\n // meant \"publish\" on one surface and \"stage\" on another. The publishable()\n // behavior auto-sets `publishedAt` on the transition. The publish-permission\n // gate lives in the admin route, not here.\n const updateData = projectToEntityFields(ctx.entity, { ...draftData, status: 'published' })\n const result = await applyLocaleRoutedUpdate(txCtx, entityId, updateData, locale, tx)\n\n // F009 — promote the page's chrome overrides.\n //\n // `projectToEntityFields` drops every `internal: true` field, deliberately, so\n // `_overrides` cannot ride the generic promotion. That guard is NOT relaxed:\n // this is one named field, written through the trusted `updateInternal` path\n // that `with-overrides.ts` documents as \"the only path that can mutate it\".\n // Before F009 that call site did not exist, so an override survived in the\n // draft buffer and never reached the column — the op committed, the canvas\n // updated, and a reload brought the hidden header back.\n //\n // AFTER the main update, so the override never lands against content that\n // failed to — and inside the same transaction, so neither lands alone.\n // `_overrides` is not translatable, so it belongs on the base row for every\n // locale.\n const overrides = readOverridesForPublish(ctx.entity, draftData)\n if (overrides !== undefined) {\n await ctx.admin.updateInternal(entityId, { _overrides: overrides }, { tx })\n }\n\n if (ctx.versionableOptions) {\n await createVersionSnapshot(txCtx, entityId, result, previousState, locale, user)\n }\n\n return result\n })\n}\n\n/**\n * The page's override map from a draft payload, when the entity declares the\n * field and the draft carries one.\n *\n * Returns `undefined` — never `{}` — so an ordinary content publish does not\n * touch the column. Shape-guarded because a draft payload is stored jsonb: an\n * array or a scalar is malformed data, not an override map. ← F009\n */\nfunction readOverridesForPublish(\n entity: { allFields: Record<string, unknown>; behaviors?: readonly { name: string }[] },\n draftData: Record<string, unknown>,\n): Record<string, unknown> | undefined {\n // The BEHAVIOR, not just a field with the right name — a draft payload is\n // stored jsonb and can be crafted or legacy, so this must not become a way\n // to promote arbitrary JSON into an unrelated internal field on an entity\n // that never opted in. Requiring the behavior also means `beforeUpdate` runs\n // `validateOverridesMap` on the way through `updateInternal`.\n if (!(entity.behaviors ?? []).some((b) => b.name === 'withOverrides')) return undefined\n const field = entity.allFields._overrides as { internal?: boolean } | undefined\n if (field === undefined || field.internal !== true) return undefined\n const value = draftData._overrides\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined\n return value as Record<string, unknown>\n}\n\n/**\n * Quick existence check for list badges — does a draft exist?\n */\nexport async function hasDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return false\n return client.exists({ entityId, locale })\n}\n\n/**\n * Count drafts for this entity type, optionally filtered by locale.\n * Single indexed COUNT — used by dashboard widgets.\n */\nexport async function countDrafts(\n ctx: ContentContext,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<number> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return 0\n return client.count({ locale })\n}\n\n/**\n * Check draft existence for multiple entities in one query. Returns\n * a set of entity IDs that have a draft for `locale`.\n */\nexport async function hasDraftBatch(\n ctx: ContentContext,\n entityIds: string[],\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<Set<string>> {\n validateLocale(locale)\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n const client = await getDraftsClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n return result\n}\n\n/**\n * Compare the current draft with the live entity. Returns field-level\n * diffs between the published data and the draft (only for fields\n * present in the draft — missing fields are NOT \"changed to empty\").\n */\nexport async function compareDraftWithCurrent<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n current: InferEntityDTO<F>\n draft: DraftEntry<InferUpdateInput<F>>\n} | null> {\n validateLocale(locale)\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) return null\n\n // Fetch the live entity with the same locale context as the draft.\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions =\n locale === DEFAULT_LOCALE_SENTINEL ? { defaultLocale } : { locale, defaultLocale }\n const current = await ctx.admin.findById(entityId, findOptions)\n if (!current) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const currentRecord = current as Record<string, unknown>\n const draftRecord = draft.data as Record<string, unknown>\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draftRecord)) {\n currentSubset[key] = currentRecord[key]\n }\n const diffs = diffFields(currentSubset, draftRecord, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Per-locale publish status — independent draft/published state for\n * each locale of a publishable + translatable entity.\n *\n * Capability: requires `publishable() + at least one translatable\n * field` (the `_locale_status` table is registered only for that\n * combination). Reads silently return falsy on entities without it;\n * `setLocaleStatus` throws `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { EntityCapabilityError } from './errors.js'\nimport { type ContentContext, getLocaleStatusClient } from './internal/context.js'\nimport { upsertLocaleStatusRow } from './locale-status-slot.js'\nimport { validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { LocalePublishStatus } from './types.js'\n\n/**\n * Get per-locale publish status overrides for an entity.\n * Missing locale = inherits the base entity status (no row written).\n *\n * **Not permission-gated, deliberately — see {@link getLocaleStatusBatch}.**\n */\nexport async function getLocaleStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<LocalePublishStatus[]> {\n const map = await getLocaleStatusBatch(ctx, [entityId])\n const entry = map.get(entityId)\n if (!entry) return []\n return Object.values(entry)\n}\n\n/**\n * Set per-locale publish status. Upserts into the locale_status\n * table — except when `locale === defaultLocale`, which updates the\n * base entity status column directly (the per-locale table only\n * stores overrides for non-default locales).\n *\n * **Both branches are publish-gated by the entity layer (D041).** The default\n * branch inherits it from `AdminClient.update`; the non-default branch routes\n * through `AdminClient.updateForLocale`, which asserts the SAME\n * `assertPublishAuthority` from the caller's payload before it hands the write\n * to the locale-status writer. This is deliberately a delegation and not a\n * second predicate in this package: `<entity>_locale_status` is the table the\n * public read predicate (`COALESCE(locale_status.status, main.status)`) resolves\n * against, so a direct `TableClient.upsert` here — which is what this function\n * used to do — is a fourth publish door, reachable from any plugin/route/job\n * holding a `ContentClient` (a PUBLIC subpath export) with no `publish` grant\n * ever consulted.\n */\nexport async function setLocaleStatus<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n validateLocale(locale)\n const config = getContentConfig()\n\n // Default locale → update base entity status column.\n if (locale === config.defaultLocale) {\n const data: Record<string, unknown> = { status }\n if (status === 'published') data.publishedAt = new Date()\n await ctx.admin.update(entityId, data as InferUpdateInput<F>)\n return\n }\n\n const client = await getLocaleStatusClient(ctx)\n if (!client) {\n // Per-locale status only exists for publishable + translatable\n // entities. Calling on a non-translatable publishable (where the\n // only valid locale is the default) is a programmer error.\n // Checked BEFORE delegating: `updateForLocale` would otherwise fall back to\n // the base `status` column for a missing writer, silently publishing the\n // default locale instead of refusing.\n throw new EntityCapabilityError(ctx.entity.name, 'localeStatus')\n }\n\n // The writer we hand down is the RAW upsert, not this function — the\n // delegation terminates here rather than re-entering `updateForLocale`.\n //\n // Routing through `updateForLocale` rather than calling the upsert directly\n // is what closes the publish door: this branch used to write\n // `<entity>_locale_status` with no permission check at all, so any holder of\n // this public export could make a translation publicly live without\n // `publish`. `updateForLocale` asserts publish authority at the operation\n // entry (D041), so the gate is inherited rather than duplicated here.\n //\n // Widened through `Record<string, unknown>` for the same reason the\n // default-locale branch above does it: `InferUpdateInput<F>` is keyed on the\n // entity's own field map, which a generic `F` cannot be proven to overlap.\n const localeData: Record<string, unknown> = { status }\n await ctx.admin.updateForLocale(entityId, localeData as InferUpdateInput<F>, locale, {\n defaultLocale: config.defaultLocale,\n localeStatusWriter: (id, loc, s) => upsertLocaleStatusRow(client, id, loc, s),\n })\n}\n\n/**\n * Get per-locale publish status for multiple entities in one query.\n *\n * ## Why this read is NOT permission-gated (and what would gate it)\n *\n * It returns publish-state METADATA only — `{ locale, status, publishedAt }` —\n * never field data, so it is not a content-disclosure door the way the write\n * side above is a publish door. What it does expose is an existence oracle for\n * unpublished TRANSLATIONS: a principal without `view_unpublished` learns that\n * `et` is a draft, which `QueryClient` would have hidden. The correct predicate\n * is therefore `view_unpublished` (D017), NOT `publish`.\n *\n * A CodeRabbit pass proposed gating it on exactly that predicate. Deliberately\n * declined, on the strength of **F063**: `view_unpublished` governs `QueryClient`\n * reads — the PUBLIC delivery surface — and is not a disclosure boundary over\n * the admin API, where `AdminClient` shows editors their own drafts by design.\n * `ContentClient` is an in-process server-side API whose consumers are plugins,\n * routes and jobs on the admin side, so gating it here would extend the control\n * onto the surface F063 explicitly recommended NOT extending it to. The concrete\n * cost would be an editor lacking `view_unpublished` no longer able to see that\n * their OWN translation is still a draft.\n *\n * The asymmetry with the write side above is real, not convenient:\n * `setLocaleStatus` MINTS public visibility, so it is gated regardless of which\n * surface calls it. This read hands publish-state metadata to a caller who\n * already holds `view` on the row — they needed its id to ask.\n *\n * It is not applied here because the predicate (`mayViewUnpublishedEntity`) is\n * module-private to `@murumets-ee/entity` — re-deriving it in this package\n * would create exactly the second, drifting copy of a security predicate that\n * D041 exists to eliminate, and there is no read-path equivalent of\n * `updateForLocale` to delegate to. The one in-repo caller\n * (`admin-ui`'s versions route) is already gated by `checkPermission`. Closing\n * this properly means exporting the predicate from `@murumets-ee/entity` and\n * dropping non-`published` rows for principals without it — tracked as the\n * read-side sibling of F060.\n */\nexport async function getLocaleStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, Record<string, LocalePublishStatus>>> {\n const result = new Map<string, Record<string, LocalePublishStatus>>()\n if (entityIds.length === 0) return result\n const client = await getLocaleStatusClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n let entry = result.get(eid)\n if (!entry) {\n entry = {}\n result.set(eid, entry)\n }\n entry[locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n return result\n}\n","/**\n * Translation status reporting — per-locale completion of translatable\n * fields for one entity, batches of entities, or the whole entity type.\n *\n * Capability: requires the entity to have at least one translatable\n * field. All methods return falsy / empty when the entity has no\n * translatable fields (read-only and defensive).\n */\n\nimport 'server-only'\n\nimport { getContext, requireSiteFor } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { buildPartitionCondition, type FieldConfig } from '@murumets-ee/entity'\nimport { and, inArray } from 'drizzle-orm'\nimport { InvalidLocaleError } from './errors.js'\nimport type { ContentContext } from './internal/context.js'\nimport { getContentConfig } from './plugin.js'\nimport type { TranslationReport, TranslationStatus } from './types.js'\n\n/**\n * The locales a status report covers and the default it skips: the request's\n * — which the admin narrows to its SITE's locales and default (plan `multisite`\n * phase 03) — else the configured content locales.\n */\nfunction statusLocales(): { locales: { code: string }[]; defaultLocale: string } {\n const config = getContentConfig()\n const ctx = getContext()\n const codes = ctx?.availableLocales ?? config.locales.map((l) => l.code)\n return {\n locales: codes.map((code) => ({ code })),\n defaultLocale: ctx?.defaultLocale ?? config.defaultLocale,\n }\n}\n\n/**\n * For one entity, compute per-locale completion status across every\n * translatable field. Default-locale rows are skipped.\n */\nfunction computeStatusForEntity(\n translatableFields: string[],\n translatedByLocale: Map<string, Record<string, unknown>> | undefined,\n locales: { code: string }[],\n defaultLocale: string,\n): TranslationStatus {\n const status: TranslationStatus = {}\n for (const locale of locales) {\n if (locale.code === defaultLocale) continue\n const translated = translatedByLocale?.get(locale.code) ?? {}\n const missing: string[] = []\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatableFields.length - missing.length,\n missing,\n }\n }\n return status\n}\n\n/**\n * Get translation status for an entity — per-locale completion report.\n */\nexport async function getTranslationStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<TranslationStatus> {\n // Single-entity is the batch path with one ID — keeps the two\n // codepaths from drifting.\n const map = await getTranslationStatusBatch(ctx, [entityId])\n return map.get(entityId) ?? {}\n}\n\n/**\n * Get translation status for multiple entities in a single query.\n * O(1) DB roundtrips regardless of `entityIds.length`.\n */\nexport async function getTranslationStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const { locales, defaultLocale } = statusLocales()\n\n const translatableFields = Object.entries(ctx.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK\n // refs); accessed via schemaRegistry + direct Drizzle here.\n const translationTable = schemaRegistry.get(`${ctx.entity.name}_translations`)\n if (!translationTable) return result\n\n // A site-bound entity's translation rows are confined to the request's\n // site (multisite D003): ids alone are no boundary — a caller holding\n // another site's id must learn nothing about its translations.\n const site = await requireSiteFor(ctx.entity)\n const byIds = inArray(translationTable.entityId, entityIds)\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(\n site !== undefined\n ? and(byIds, buildPartitionCondition(translationTable, { site }, ctx.entity.name))\n : byIds,\n )\n\n // Group: entityId → locale → { fieldName: value }\n const translationsByEntity = new Map<string, Map<string, Record<string, unknown>>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const eid = row.entityId as string\n const locale = row.locale as string\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n let entry = translationsByEntity.get(eid)\n if (!entry) {\n entry = new Map()\n translationsByEntity.set(eid, entry)\n }\n entry.set(locale, fieldValues)\n }\n\n for (const entityId of entityIds) {\n result.set(\n entityId,\n computeStatusForEntity(\n translatableFields,\n translationsByEntity.get(entityId),\n locales,\n defaultLocale,\n ),\n )\n }\n\n return result\n}\n\n/**\n * Roll up translation status across every entity of this type.\n *\n * Defaults to the first non-default configured locale; pass\n * `options.locale` to target a specific one. Returns counts of\n * complete / partial / missing entities for that locale.\n */\nexport async function getTranslationReport(\n ctx: ContentContext,\n options?: { locale?: string },\n): Promise<TranslationReport> {\n const { locales, defaultLocale } = statusLocales()\n const targetLocale = options?.locale ?? locales.find((l) => l.code !== defaultLocale)?.code\n if (!targetLocale) return { total: 0, complete: 0, partial: 0, missing: 0 }\n // A locale the site does not serve has no report on that site.\n if (!locales.some((l) => l.code === targetLocale)) {\n throw new InvalidLocaleError(\n targetLocale,\n locales.map((l) => l.code),\n )\n }\n\n const entities = await ctx.admin.findMany()\n if (entities.length === 0) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n // Single batched query covers every entity at once.\n const statusMap = await getTranslationStatusBatch(\n ctx,\n entities.map((e) => e.id),\n )\n\n let complete = 0\n let partial = 0\n let missing = 0\n for (const entity of entities) {\n const status = statusMap.get(entity.id)\n const localeStatus = status?.[targetLocale]\n if (!localeStatus || localeStatus.translated === 0) missing++\n else if (localeStatus.complete) complete++\n else partial++\n }\n\n return { total: entities.length, complete, partial, missing }\n}\n","/**\n * ContentClient — thin shell over per-feature modules.\n *\n * Wraps AdminClient with version history (`./versions.ts`), draft\n * overlays (`./drafts.ts`), translation status reporting\n * (`./translation-status.ts`), per-locale publish status\n * (`./locale-status.ts`), and block-layout operations (`./layout.ts`).\n *\n * The class is generic over the entity's `AllFields` map: when\n * constructed with a typed entity, every method's input/output is\n * inferred end-to-end. When constructed with a runtime `Entity`\n * (admin-ui's heterogeneous entity loops), the generic defaults to\n * `Record<string, FieldConfig>` and methods accept/return the loose\n * shape — same behavior as the pre-generic version.\n *\n * Usage:\n * import { createContentClient } from '@murumets-ee/content/client'\n * const pages = createContentClient(Page)\n * await pages.create({ title: 'Hello', layout: [...] })\n */\n\nimport 'server-only'\n\nimport { getApp, type Logger } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type {\n AdminClient,\n FindManyOptions,\n InferCreateInput,\n InferEntityDTO,\n InferUpdateInput,\n} from '@murumets-ee/entity/admin'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport {\n compareDraftWithCurrent,\n countDrafts,\n deleteDraft,\n getDraft,\n hasDraft,\n hasDraftBatch,\n mergeDraft,\n publishDraft,\n saveDraft,\n} from './drafts.js'\nimport { type ContentContext, createContentContext } from './internal/context.js'\nimport { calculateDelta } from './internal/projection.js'\nimport { getLocaleStatus, getLocaleStatusBatch, setLocaleStatus } from './locale-status.js'\nimport {\n getTranslationReport,\n getTranslationStatus,\n getTranslationStatusBatch,\n} from './translation-status.js'\nimport {\n compareVersions,\n createVersionSnapshot,\n getVersion,\n getVersions,\n pruneVersions,\n restoreVersion,\n saveVersionSnapshot,\n writeVersionColumn,\n} from './versions.js'\n\nexport interface ContentClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n}\n\nexport class ContentClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private ctx: ContentContext<AllFields>\n\n constructor(config: ContentClientConfig<AllFields>) {\n const versionableOptions = getVersionableOptions(config.entity.behaviors)\n this.ctx = createContentContext(config, versionableOptions)\n }\n\n // -----------------------------------------------------------------\n // CRUD (delegated to AdminClient + version snapshots)\n // -----------------------------------------------------------------\n\n async create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n const result = await this.ctx.admin.create(data)\n if (this.ctx.versionableOptions) {\n await saveVersionSnapshot(this.ctx, result.id, result, null, false)\n }\n return result\n }\n\n findById(id: string, options?: { locale?: string }): Promise<InferEntityDTO<AllFields> | null> {\n return this.ctx.admin.findById(id, options)\n }\n\n findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n return this.ctx.admin.findMany(options)\n }\n\n /**\n * Base-row update + version snapshot. Subject to AdminClient's\n * cross-locale write guard (SD021): under a non-default request-context\n * locale, translatable fields throw `CrossLocaleWriteError` — that's\n * enforcement, not a gap. Locale-belonging writes go through the draft\n * flow (`saveDraft` → `publishDraft`) or `admin.updateForLocale` directly.\n */\n async update(id: string, data: InferUpdateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n let previousState: InferEntityDTO<AllFields> | null = null\n if (this.ctx.versionableOptions) {\n previousState = await this.ctx.admin.findById(id)\n }\n\n const result = await this.ctx.admin.update(id, data)\n\n if (this.ctx.versionableOptions && previousState) {\n const delta = calculateDelta(previousState, result)\n const version = await saveVersionSnapshot(this.ctx, id, result, delta, false)\n if (version !== undefined) {\n await writeVersionColumn(this.ctx, id, version)\n await pruneVersions(this.ctx, id)\n return { ...result, _version: version } as InferEntityDTO<AllFields>\n }\n await pruneVersions(this.ctx, id)\n }\n\n return result\n }\n\n delete(id: string): Promise<unknown> {\n return this.ctx.admin.delete(id)\n }\n\n // -----------------------------------------------------------------\n // Translation delegation\n // -----------------------------------------------------------------\n\n saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n return this.ctx.admin.saveTranslation(entityId, locale, translations)\n }\n\n deleteTranslation(entityId: string, locale?: string) {\n return this.ctx.admin.deleteTranslation(entityId, locale)\n }\n\n // -----------------------------------------------------------------\n // Version history\n // -----------------------------------------------------------------\n\n getVersions(entityId: string, options?: { limit?: number; offset?: number; locale?: string }) {\n return getVersions(this.ctx, entityId, options)\n }\n\n getVersion(entityId: string, version: number, locale?: string) {\n return getVersion(this.ctx, entityId, version, locale)\n }\n\n compareVersions(entityId: string, v1: number, v2: number, locale?: string) {\n return compareVersions(this.ctx, entityId, v1, v2, locale)\n }\n\n restoreVersion(entityId: string, version: number, locale?: string) {\n return restoreVersion(this.ctx, entityId, version, locale)\n }\n\n pruneVersions(entityId: string, locale?: string) {\n return pruneVersions(this.ctx, entityId, locale)\n }\n\n createVersionSnapshot(\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return createVersionSnapshot(this.ctx, entityId, currentData, previousData, locale, user)\n }\n\n // -----------------------------------------------------------------\n // Translation status\n // -----------------------------------------------------------------\n\n getTranslationStatus(entityId: string) {\n return getTranslationStatus(this.ctx, entityId)\n }\n\n getTranslationStatusBatch(entityIds: string[]) {\n return getTranslationStatusBatch(this.ctx, entityIds)\n }\n\n getTranslationReport(options?: { locale?: string }) {\n return getTranslationReport(this.ctx, options)\n }\n\n // -----------------------------------------------------------------\n // Per-locale publish status\n // -----------------------------------------------------------------\n\n getLocaleStatus(entityId: string) {\n return getLocaleStatus(this.ctx, entityId)\n }\n\n setLocaleStatus(entityId: string, locale: string, status: 'draft' | 'published') {\n return setLocaleStatus(this.ctx, entityId, locale, status)\n }\n\n getLocaleStatusBatch(entityIds: string[]) {\n return getLocaleStatusBatch(this.ctx, entityIds)\n }\n\n // -----------------------------------------------------------------\n // Drafts\n // -----------------------------------------------------------------\n\n saveDraft(\n entityId: string,\n data: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return saveDraft(this.ctx, entityId, data, locale, user)\n }\n\n mergeDraft(\n entityId: string,\n patch: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return mergeDraft(this.ctx, entityId, patch, locale, user)\n }\n\n getDraft(entityId: string, locale?: string) {\n return getDraft(this.ctx, entityId, locale)\n }\n\n deleteDraft(entityId: string, locale?: string) {\n return deleteDraft(this.ctx, entityId, locale)\n }\n\n publishDraft(entityId: string, locale?: string, user?: { id: string; name?: string }) {\n return publishDraft(this.ctx, entityId, locale, user)\n }\n\n hasDraft(entityId: string, locale?: string) {\n return hasDraft(this.ctx, entityId, locale)\n }\n\n countDrafts(locale?: string) {\n return countDrafts(this.ctx, locale)\n }\n\n hasDraftBatch(entityIds: string[], locale?: string) {\n return hasDraftBatch(this.ctx, entityIds, locale)\n }\n\n compareDraftWithCurrent(entityId: string, locale?: string) {\n return compareDraftWithCurrent(this.ctx, entityId, locale)\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity. Must be called\n * after `createApp()`. Generic flows from the entity definition:\n * `createContentClient(Article)` returns a `ContentClient<typeof\n * Article['allFields']>` typed end-to-end.\n */\nexport function createContentClient<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n): ContentClient<AllFields> {\n const admin = createAdminClient(entity)\n return new ContentClient<AllFields>({\n admin,\n entity,\n logger: getApp().logger.child({ content: entity.name }),\n })\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/content",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -56,14 +56,14 @@
|
|
|
56
56
|
"drizzle-orm": "^0.45.2",
|
|
57
57
|
"server-only": "^0.0.1",
|
|
58
58
|
"zod": "^3.24.1",
|
|
59
|
-
"@murumets-ee/blocks": "0.
|
|
60
|
-
"@murumets-ee/core": "0.
|
|
61
|
-
"@murumets-ee/db": "0.
|
|
62
|
-
"@murumets-ee/entity": "0.
|
|
63
|
-
"@murumets-ee/i18n": "0.
|
|
64
|
-
"@murumets-ee/logging": "0.
|
|
65
|
-
"@murumets-ee/queue": "0.
|
|
66
|
-
"@murumets-ee/settings": "0.
|
|
59
|
+
"@murumets-ee/blocks": "0.90.0",
|
|
60
|
+
"@murumets-ee/core": "0.90.0",
|
|
61
|
+
"@murumets-ee/db": "0.90.0",
|
|
62
|
+
"@murumets-ee/entity": "0.90.0",
|
|
63
|
+
"@murumets-ee/i18n": "0.90.0",
|
|
64
|
+
"@murumets-ee/logging": "0.90.0",
|
|
65
|
+
"@murumets-ee/queue": "0.90.0",
|
|
66
|
+
"@murumets-ee/settings": "0.90.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@types/node": "^20.19.42",
|