@murumets-ee/content 0.75.1 → 0.76.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.
- package/dist/admin.mjs +1 -1
- package/dist/block-resolver.d.mts +5 -0
- package/dist/block-resolver.d.mts.map +1 -1
- package/dist/block-resolver.mjs +1 -1
- package/dist/block-resolver.mjs.map +1 -1
- package/dist/client.mjs +1 -1
- package/dist/enumerate-CBqu63Kp.mjs +2 -0
- package/dist/{enumerate-B5tWe9JC.mjs.map → enumerate-CBqu63Kp.mjs.map} +1 -1
- package/dist/plugin-BxOHoKPR.mjs +2 -0
- package/dist/{plugin-DEYaEeWC.mjs.map → plugin-BxOHoKPR.mjs.map} +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/resolve-context-MRZZ3rQl.mjs +2 -0
- package/dist/{resolve-context-rr-Eanwp.mjs.map → resolve-context-MRZZ3rQl.mjs.map} +1 -1
- package/dist/resolve.mjs +2 -2
- package/dist/resolve.mjs.map +1 -1
- package/dist/routes-C9I1iZw8.mjs +2 -0
- package/dist/{routes-DuJa-w4T.mjs.map → routes-C9I1iZw8.mjs.map} +1 -1
- package/dist/schema-0zayzpD_.mjs +2 -0
- package/dist/schema-0zayzpD_.mjs.map +1 -0
- package/dist/schema-D8sBQozw.mjs +2 -0
- package/dist/schema-D8sBQozw.mjs.map +1 -0
- package/dist/seed.d.mts +20 -1
- package/dist/seed.d.mts.map +1 -1
- package/dist/seed.mjs +1 -1
- package/dist/seed.mjs.map +1 -1
- package/dist/{versions-qld3n7B-.mjs → versions-BDxB6k_S.mjs} +2 -2
- package/dist/{versions-qld3n7B-.mjs.map → versions-BDxB6k_S.mjs.map} +1 -1
- package/package.json +9 -9
- package/dist/apply-path-s-YeCUXP.mjs +0 -2
- package/dist/apply-path-s-YeCUXP.mjs.map +0 -1
- package/dist/enumerate-B5tWe9JC.mjs +0 -2
- package/dist/plugin-DEYaEeWC.mjs +0 -2
- package/dist/resolve-context-rr-Eanwp.mjs +0 -2
- package/dist/routes-DuJa-w4T.mjs +0 -2
- package/dist/schema-BPDn8ajz.mjs +0 -2
- package/dist/schema-BPDn8ajz.mjs.map +0 -1
package/dist/admin.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e,r as t,t as n}from"./routes-
|
|
1
|
+
import{n as e,r as t,t as n}from"./routes-C9I1iZw8.mjs";export{e as PATH_RESOLVE_PERMISSION,t as pathRoutes,n as redirectRoutes};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":";;;;;;UA8GiB,oBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EACA,QAAA,EAAU,MAAA;EACV,OAAA,EAAS,MAAA,SAAe,KAAA;IAAQ,GAAA;IAAa,KAAA;IAAe,MAAA;EAAA;EAC5D,UAAA;IAAc,CAAA;IAAW,CAAA;EAAA;EACzB,WAAA;AAAA;;;;;;;;KAUU,kBAAA,IACV,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,oBAAA;;UAGR,kBAAA;EAAA,SACN,IAAA;EAAA,SACA,GAAA;EAAA,SACA,MAAA;AAAA
|
|
1
|
+
{"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":";;;;;;UA8GiB,oBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EACA,QAAA,EAAU,MAAA;EACV,OAAA,EAAS,MAAA,SAAe,KAAA;IAAQ,GAAA;IAAa,KAAA;IAAe,MAAA;EAAA;EAC5D,WAAA,GAAc,MAAA,SAAe,KAAA;IAAQ,GAAA;IAAa,KAAA;IAAe,MAAA;EAAA;EACjE,UAAA;IAAc,CAAA;IAAW,CAAA;EAAA;EACzB,WAAA;AAAA;;;;;;;;KAUU,kBAAA,IACV,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,oBAAA;;UAGR,kBAAA;EAAA,SACN,IAAA;EAAA,SACA,GAAA;EAAA,SACA,MAAA;AAAA;AA4BiD;AAAA,UAxB3C,YAAA;EAAA,SACN,MAAA;EAAA,SACA,EAAE;AAAA;;UAII,iBAAA;EAAA,SACN,QAAA;EAAA,SACA,MAAA;EAAA,SACA,KAAA,EAAO,QAAA,CAAS,MAAA;EAAA,SAChB,UAAA,YAAsB,kBAAA;EAAA,SACtB,IAAA,YAAgB,YAAA;EAAA,SAChB,WAAA,GAAc,QAAA,CAAS,MAAA,SAAe,gBAAA;AAAA;;;;;UAOhC,mBAAA;EAAA,SACN,OAAA,WAAkB,oBAAA;EAAA,SAClB,aAAA,GAAgB,SAAA;EAAA,SAChB,SAAA,GAAY,IAAA,aAAiB,MAAA;EAqBvB;EAAA,SAnBN,SAAA,GAAY,MAAA,EAAQ,MAAA,KAAW,kBAAA;AAAA;;;;;;;UASzB,qBAAA;EACf,KAAA,GAAQ,IAAA,WAAe,CAAA;AAAA;AAAA,UAGR,mBAAA;EACf,KAAA,GAAQ,GAAA,UAAa,GAAA;EACrB,IAAA,GAAO,GAAA,UAAa,GAAA;AAAA;;UAIL,iBAAA;EAgBF;EAdb,YAAA,GAAe,kBAAA;EAeN;;;;;;;;;EALT,qBAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,GAAA;EACb,MAAA,GAAS,mBAAA;EAyBkC;EAvB3C,WAAA,GAAc,mBAAA;EAiWM;;;;;;;;EAxVpB,mBAAA,KAEM,QAAA,UACA,SAAA,EAAW,iBAAA;IAAA,SAEF,MAAA;IAAA,SACA,YAAA;EAAA;EAqVb;;;;;EA7UF,cAAA,KAAmB,IAAA;AAAA;;;;;;;;;;;;AA2VO;AAwgB5B;;;;;;iBAzhBsB,gBAAA,CACpB,MAAA,WACA,IAAA;EACE,MAAA;EACA,MAAA,EAAQ,qBAAA;EACR,MAAA;EACA,KAAA,UA4iBC;EA1iBD,aAAA,KAAkB,UAAA,mCA0iBV;EAxiBR,eAAA,gCAghBoB;EA9gBpB,QAAA,uBA8gBF;EA5gBE,WAAA,GAAc,QAAA,CAAS,MAAA;AAAA,GAEzB,IAAA,EAAM,iBAAA,GACL,OAAA,CAAQ,iBAAA;;;;;;;;;;;;;;;;;iBAwgBK,mBAAA,CACd,MAAA,QAAc,OAAA,CAAQ,UAAA,IAAc,UAAA,EACpC,OAAA;EAuBW;;AAAiB;;;;;EAf1B,aAAA;EACA,YAAA,GAAe,kBAAA;AAAA,KAGjB,MAAA,WACA,IAAA;EACE,MAAA;EACA,MAAA,EAAQ,qBAAA;EACR,MAAA;EACA,KAAA;EACA,aAAA,KAAkB,UAAA;EAClB,eAAA;EACA,QAAA;EACA,WAAA,GAAc,QAAA,CAAS,MAAA;AAAA,MAEtB,OAAA,CAAQ,iBAAA"}
|
package/dist/block-resolver.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as e}from"./resolve-context-
|
|
1
|
+
import{r as e}from"./resolve-context-MRZZ3rQl.mjs";import{r as t}from"./enumerate-CBqu63Kp.mjs";import{collectLinkValues as n,linkValueKey as r}from"@murumets-ee/blocks/links";import{collectBoundColumnsInTree as i,createCollectionReader as a,effectiveCollectionSources as o,materializeServerTemplate as s,resolveLinks as c,resolveTreeCollections as l,serverBlockStructureLookup as u,serverBoundColumnsInTree as d,serverCollectionFieldLookup as f,serverTemplateMaterializer as p,templateNamespace as m}from"@murumets-ee/blocks/server";const h=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function g(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function _(e){return Array.isArray(e)}const v=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}));function y(e,t){if(!g(e))return null;let n=e.id;if(typeof n!=`string`||!h.test(n)||typeof e.url!=`string`||e.url===``)return null;let r=Object.keys(e).filter(e=>!v.has(e));return r.length>0?(t?.(r),null):!b(e.alt,`string`)||!b(e.title,`string`)||!b(e.mimeType,`string`)||!b(e.width,`number`)||!b(e.height,`number`)||!b(e.blurDataUrl,`string`)||e.variants!==void 0&&e.variants!==null&&!g(e.variants)||e.focalPoint!==void 0&&e.focalPoint!==null&&!g(e.focalPoint)||e.sources!==void 0&&e.sources!==null&&(!g(e.sources)||!Object.values(e.sources).every(e=>Array.isArray(e)))||e.avifSources!==void 0&&e.avifSources!==null&&(!g(e.avifSources)||!Object.values(e.avifSources).every(e=>Array.isArray(e)))?null:n}function b(e,t){return e==null||typeof e===t}function x(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)){for(let a=0;a<e.length;a+=1){let o=y(e[a],i);o===null?x(e[a],t,n+1,r):t.push({container:e,key:a,id:o})}return}if(g(e))for(let[a,o]of Object.entries(e)){let s=y(o,i);s===null?x(o,t,n+1,r):t.push({container:e,key:a,id:s})}}function S(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 C(e,t){let n=t===null?null:S(t);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 w(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??s)(t.template,m(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))try{o=(i.boundColumnsOf??d)(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 l=t.boundValues??{},u=o.filter(e=>Object.hasOwn(l,e)).map(e=>l[e]);if(!_(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 f=[],p={truncated:!1,nearMissKeys:new Set};x(a,f,0,p),p.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`),p.nearMissKeys.size>0&&i.logger?.debug({entity:t.entity,field:t.field,keys:[...p.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 g=[...n(a,64),...n(u,64)],v=[],y=[];for(let e of g)e.kind===`media`&&(h.test(e.id)?v.push(e.id):y.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let b=D([...f.map(e=>e.id),...v]),S=[];for(let e of g)e.kind===`entity`&&(h.test(e.id)?S.push(O(e.entity,e.id)):y.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let w=[...y];!i.resolveMedia&&b.length>0&&i.logger?.debug({entity:t.entity,field:t.field,count:b.length},`content: no media resolver wired for block trees — every media ref resolves to null`);let M=i.resolveMedia?t.budget.claim(`block`,b.length):0,N=b.slice(0,M),P=i.resolveMedia?new Set(b.slice(M)):new Set;for(let e of b.slice(M))w.push({kind:`media`,ref:e,reason:P.has(e)?`budget`:`unresolvable`});let F=D(S),I=t.budget.claim(`block`,F.length),L=F.slice(0,I),R=new Set(F.slice(I)),z=await A(N,t.locale,i,t.entity);for(let e of f)C(e,z.get(e.id)??null);for(let e of N)z.has(e)||w.push({kind:`media`,ref:e,reason:`unresolvable`});let B=await j(L,t.locale,i,t.entity),V=await c(g,{renderContext:`request`,locale:t.locale,resolveEntityHref:({entity:e,id:t})=>B.get(O(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 g){if(e.kind!==`entity`&&e.kind!==`media`)continue;let t=r(e);H[t]!==null&&H[t]!==void 0||w.push({kind:`link`,ref:t,reason:E(e,R,P)})}let{results:U,cardMedia:W}=await T(a,t,i),G=new Map;for(let e of z.keys())G.set(O(`media`,e),{entity:`media`,id:e});for(let e of W)G.set(O(`media`,e),{entity:`media`,id:e});for(let e of U.values())if(e.entity!==null)for(let t of e.rows)G.set(O(e.entity,t.id),{entity:e.entity,id:t.id});for(let e of B.keys()){if(G.has(e))continue;let t=e.indexOf(` `);if(t<=0)continue;let n=e.slice(0,t),r=e.slice(t+1);r!==``&&G.set(e,{entity:n,id:r})}return{resolved:1,blocks:a,links:H,...w.length>0&&{unresolved:k(w)},...G.size>0&&{refs:[...G.values()]},...U.size>0&&{collections:Object.fromEntries(U)}}}async function T(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 l(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 E(e,t,n){return e.kind===`entity`&&t.has(O(e.entity,e.id))||e.kind===`media`&&n.has(e.id)?`budget`:`unresolvable`}function D(e){return[...new Set(e)]}function O(e,t){return[e,t].join(` `)}function k(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 A(e,t,n,r){if(e.length===0||!n.resolveMedia)return new Map;try{return await n.resolveMedia(e,{locale:t})}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 j(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(O(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 M(n,r){let s=null,c=n=>{if(s?.app===n)return s.deps;let c=e(n),{sources:l}=o(n),d=l.length===0?void 0:{sources:l,sourceFieldOf:f(),getEntity:e=>n.entities.get(e),clientFor:e=>a(e,n)},m=p(),h=u(),g={...d!==void 0&&{collections:d},boundColumnsOf:e=>i(e,h),materializeTemplate:(e,t)=>m(e,t),resolveMedia:r.resolveMedia,resolvePublishedPaths:async(e,n,r)=>{let i=await c.filterPublished(e,n,r);return i.size===0?new Map:t(e,[...i],r,c)},logger:n.logger};return s={app:n,deps:g},g};return async(e,t)=>{let i=await n();return w(e,{...t,locale:t.locale??r.defaultLocale},c(i))}}export{M as createBlockResolver,w 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 CollectionResult,\n type CollectionSourceSpec,\n collectBoundColumnsInTree,\n createCollectionReader,\n effectiveCollectionSources,\n type FindManyClientLike,\n materializeServerTemplate,\n resolveLinks,\n resolveTreeCollections,\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 focalPoint: { x: number; y: number }\n blurDataUrl: string | null\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?: { locale?: string | undefined },\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}\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 readonly sourceFieldOf: (blockType: string) => string | undefined\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\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, 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 focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: 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 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}\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) out.push({ container: node, key: index, id })\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 })\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 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\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written = value === null ? null : toEmbeddedProjection(value)\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 const { results: collections, cardMedia } = await resolveCollectionsIn(tree, opts, deps)\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 }\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 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 })\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 }\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 CollectionResult,\n type CollectionSourceSpec,\n collectBoundColumnsInTree,\n createCollectionReader,\n effectiveCollectionSources,\n type FindManyClientLike,\n materializeServerTemplate,\n resolveLinks,\n resolveTreeCollections,\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\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?: { locale?: string | undefined },\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}\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 readonly sourceFieldOf: (blockType: string) => string | undefined\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\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, 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}\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 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}\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) out.push({ container: node, key: index, id })\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 })\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\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written = value === null ? null : toEmbeddedProjection(value)\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 const { results: collections, cardMedia } = await resolveCollectionsIn(tree, opts, deps)\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 }\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 })\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 }\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,t as n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{a as i,d as a,f as o,h as s,i as c,l,m as u,n as d,o as f,p,r as m,s as h,t as g,u as _}from"./versions-
|
|
1
|
+
import{t as e}from"./versionable-Br_G5VGR.mjs";import{n as t,t as n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{a as i,d as a,f as o,h as s,i as c,l,m as u,n as d,o as f,p,r as m,s as h,t as g,u as _}from"./versions-BDxB6k_S.mjs";import{n as v}from"./plugin-BxOHoKPR.mjs";import{t as y}from"./locale-utils-B0mFoV47.mjs";import{i as b,n as x,r as S}from"./context-Bw0n6P47.mjs";import{blockTreeFieldsOf as C}from"@murumets-ee/entity";import{getApp as w}from"@murumets-ee/core";import{schemaRegistry as T}from"@murumets-ee/db";import{inArray as E}from"drizzle-orm";import"server-only";import{createAdminClient as D}from"@murumets-ee/core/clients";function O(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 k(e,n,r,i=`_`,o){y(i);let s=S(e);if(!s)throw new t(e.entity.name,`drafts`);let c=a(e.entity,r);e.admin.assertBlockTreesValid(c);let l=C(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=o?.id??``,f=o?.name??null;return O(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 A=[`slug`,`slugMode`,`parent`];async function j(e,n,r,i=`_`,o){y(i);let s=S(e);if(!s)throw new t(e.entity.name,`drafts`);let c=a(e.entity,r);if(Object.keys(c).length===0){let e=await s.findOne({entityId:n,locale:i});return e?O(e):null}e.admin.assertBlockTreesValid(c),e.admin.assertUpdateValid(c);let l=C(e.entity).some(({name:e})=>Object.hasOwn(c,e)),u=e.entity.frontend?.path!==void 0&&A.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=>A.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 O(await s.upsert({entityId:n,locale:i,data:c,createdBy:o?.id??``,createdByName:o?.name??null,createdAt:f,updatedAt:f},{target:[`entityId`,`locale`],set:{updatedAt:f},mergeJsonb:[`data`]}))}async function M(e,t,n=`_`){y(n);let r=S(e);if(!r)return null;let i=await r.findOne({entityId:t,locale:n});return i?O(i):null}async function N(e,t,n=`_`){y(n);let r=S(e);return r?(await r.deleteMany({entityId:t,locale:n})).length>0:!1}async function P(e,r,i=`_`,s){if(y(i),!S(e))throw new t(e.entity.name,`drafts`);let c=e.versionableOptions?await e.admin.findById(r,p(i)):null;return e.db.transaction(async l=>{let u={...e,db:l},f=S(u);if(!f)throw new t(e.entity.name,`drafts`);let[p]=await f.deleteMany({entityId:r,locale:i});if(!p)throw new n(r,i);let{publishedAt:m,...h}=p.data??{},g=await o(u,r,a(e.entity,{...h,status:`published`}),i,l),_=F(e.entity,h);return _!==void 0&&await e.admin.updateInternal(r,{_overrides:_},{tx:l}),e.versionableOptions&&await d(u,r,g,c,i,s),g})}function F(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 I(e,t,n=`_`){y(n);let r=S(e);return r?r.exists({entityId:t,locale:n}):!1}async function L(e,t=`_`){y(t);let n=S(e);return n?n.count({locale:t}):0}async function R(e,t,n=`_`){y(n);let r=new Set;if(t.length===0)return r;let i=S(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 z(e,t,n=`_`){y(n);let i=await M(e,t,n);if(!i)return null;let a=r().defaultLocale,o=n===`_`?{defaultLocale:a}:{locale:n,defaultLocale:a},c=await e.admin.findById(t,o);if(!c)return null;let l=e.entity.allFields,d=c,f=i.data,p={};for(let e of Object.keys(f))p[e]=d[e];let m=s(p,f,l);for(let e of m)e.type===`blockTree`&&(e.blockDiffs=u(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:m,current:c,draft:i}}async function B(e,t){let n=(await H(e,[t])).get(t);return n?Object.values(n):[]}async function V(e,n,i,a){y(i);let o=r();if(i===o.defaultLocale){let t={status:a};a===`published`&&(t.publishedAt=new Date),await e.admin.update(n,t);return}let s=b(e);if(!s)throw new t(e.entity.name,`localeStatus`);let c={status:a};await e.admin.updateForLocale(n,c,i,{defaultLocale:o.defaultLocale,localeStatusWriter:(e,t,n)=>v(s,e,t,n)})}async function H(e,t){let n=new Map;if(t.length===0)return n;let r=b(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 U(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 W(e,t){return(await G(e,[t])).get(t)??{}}async function G(e,t){let n=new Map;if(t.length===0)return n;let i=r(),a=Object.entries(e.entity.allFields).filter(([e,t])=>t.translatable).map(([e])=>e);if(a.length===0)return n;let o=T.get(`${e.entity.name}_translations`);if(!o)return n;let s=await e.db.select().from(o).where(E(o.entityId,t)),c=new Map;for(let e of s){let t=e,n=t.entityId,r=t.locale,i={};for(let e of a)i[e]=t[e];let o=c.get(n);o||(o=new Map,c.set(n,o)),o.set(r,i)}for(let e of t)n.set(e,U(a,c.get(e),i.locales,i.defaultLocale));return n}async function K(e,t){let n=r(),i=t?.locale??n.locales.find(e=>e.code!==n.defaultLocale)?.code;if(!i)return{total:0,complete:0,partial:0,missing:0};let a=await e.admin.findMany();if(a.length===0)return{total:0,complete:0,partial:0,missing:0};let o=await G(e,a.map(e=>e.id)),s=0,c=0,l=0;for(let e of a){let t=o.get(e.id)?.[i];!t||t.translated===0?l++:t.complete?s++:c++}return{total:a.length,complete:s,partial:c,missing:l}}var q=class{ctx;constructor(t){let n=e(t.entity.behaviors);this.ctx=x(t,n)}async create(e){let t=await this.ctx.admin.create(e);return this.ctx.versionableOptions&&await h(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=_(n,r),a=await h(this.ctx,e,r,t,!1);if(a!==void 0)return await l(this.ctx,e,a),await i(this.ctx,e),{...r,_version:a};await i(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 c(this.ctx,e,t)}getVersion(e,t,n){return m(this.ctx,e,t,n)}compareVersions(e,t,n,r){return g(this.ctx,e,t,n,r)}restoreVersion(e,t,n){return f(this.ctx,e,t,n)}pruneVersions(e,t){return i(this.ctx,e,t)}createVersionSnapshot(e,t,n,r,i){return d(this.ctx,e,t,n,r,i)}getTranslationStatus(e){return W(this.ctx,e)}getTranslationStatusBatch(e){return G(this.ctx,e)}getTranslationReport(e){return K(this.ctx,e)}getLocaleStatus(e){return B(this.ctx,e)}setLocaleStatus(e,t,n){return V(this.ctx,e,t,n)}getLocaleStatusBatch(e){return H(this.ctx,e)}saveDraft(e,t,n,r){return k(this.ctx,e,t,n,r)}mergeDraft(e,t,n,r){return j(this.ctx,e,t,n,r)}getDraft(e,t){return M(this.ctx,e,t)}deleteDraft(e,t){return N(this.ctx,e,t)}publishDraft(e,t,n){return P(this.ctx,e,t,n)}hasDraft(e,t){return I(this.ctx,e,t)}countDrafts(e){return L(this.ctx,e)}hasDraftBatch(e,t){return R(this.ctx,e,t)}compareDraftWithCurrent(e,t){return z(this.ctx,e,t)}};function J(e){return new q({admin:D(e),entity:e,logger:w().logger.child({content:e.name})})}export{q as ContentClient,J as createContentClient};
|
|
2
2
|
//# sourceMappingURL=client.mjs.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{a as t}from"./schema-D8sBQozw.mjs";var n=e({enumeratePaths:()=>r,getCanonicalPaths:()=>i});async function*r(e,n,r={}){let i=r.pageSize??200,a=r.publishConcurrency??5,{locale:o,entityType:s}=e,c=e.afterId;for(;;){let e={locale:o,isCanonical:!0};s!==void 0&&(e.entityType=s),c!==void 0&&(e.id={gt:c});let r=await n.pathsClient.findMany({where:e,orderBy:[{column:`id`,dir:`asc`}],limit:i});if(r.length===0)break;let l=new Map;for(let e of r){let t=l.get(e.entityType);t?t.push(e):l.set(e.entityType,[e])}let u=new Map;await t([...l.entries()],a,async([e,t])=>{let r=await n.filterPublished(e,t.map(e=>e.entityId),o);u.set(e,r)});for(let e of r)u.get(e.entityType)?.has(e.entityId)&&(yield{path:e.path,entityType:e.entityType,id:e.entityId,updatedAt:e.updatedAt});let d=r[r.length-1];if(!d||(c=d.id,r.length<i))break}}async function i(e,t,n,r){let i=new Map;for(let a=0;a<t.length;a+=500){let o=t.slice(a,a+500),s=await r.pathsClient.findMany({where:{entityType:e,locale:n,isCanonical:!0,entityId:{in:[...o]}},limit:o.length});for(let e of s)i.set(e.entityId,e.path)}return i}export{n,i as r,r as t};
|
|
2
|
+
//# sourceMappingURL=enumerate-CBqu63Kp.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"enumerate-B5tWe9JC.mjs","names":[],"sources":["../src/paths/enumerate.ts"],"sourcesContent":["/**\n * Path enumeration for SSG / sitemaps (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2).\n *\n * `enumeratePaths` streams the PUBLISHED canonical `(path, entityType, id,\n * updatedAt)` rows for one locale. It powers `getStaticPaths` + the sitemap\n * helper, so it must NEVER leak an unpublished document (CLAUDE.md #1 — the\n * output feeds public build artifacts).\n *\n * **Security + availability.** Two properties matter here:\n * 1. *No unpublished leak.* `toolkit_paths` carries no publish-status column,\n * so each page's rows are publish-checked against the live entity via the\n * injected {@link EnumeratePathsDeps.filterPublished} — an unpublished (or\n * trashed) document is dropped from the stream.\n * 2. *No unbounded fan-out* (CLAUDE.md \"no amplification\"). Work is bounded by\n * PAGE SIZE, not by total document count: rows are keyset-paginated on the\n * immutable `id` PK (one index query per page), and the publish check is\n * BATCHED — ONE call per distinct entity-type bucket per page (a handful of\n * types at most; the routable set is config-time-fixed, never\n * attacker-influenced), never one call per row.\n *\n * **Leaf module.** Every cross-package dependency (the index reader, the publish\n * filter) is injected via `deps`; this module imports no `@murumets-ee/core` and\n * no `server-only` graph. Concrete deps are built from an app in\n * `./resolve-context.ts` (`buildEnumerateDeps`), keeping the resolution surface\n * off the `lumi` CLI command graph (F027 guardrail).\n *\n * Function-form-path entities need NO special handling — their rows were already\n * materialized into `toolkit_paths` at write time (F004 / phase 05 §2), so a\n * plain index read includes them.\n */\n\nimport { mapBounded } from './apply-path.js'\n\n// ---------------------------------------------------------------------------\n// Read surface (a `type` alias, per index-reader.ts's Deviation-1 rationale, so\n// the real `ToolkitPathsClient` stays assignable to it without any cast).\n// ---------------------------------------------------------------------------\n\n/** The projected `toolkit_paths` row the enumeration + batch lookup read. */\nexport type PathEnumRow = {\n id: string\n entityType: string\n entityId: string\n locale: string\n path: string\n updatedAt: Date\n}\n\n/**\n * The (operator-carrying) where shapes enumeration issues: keyset `id > cursor`,\n * `entityId IN (...)` for the batch lookup, plus plain-value equality filters.\n * A subset of the real client's `ColumnOperators` where-clause, so the real\n * `ToolkitPathsClient` is assignable to {@link PathEnumReader}.\n */\nexport type PathEnumWhere = {\n id?: { gt: string }\n entityType?: string\n entityId?: { in: string[] }\n locale?: string\n path?: string\n isCanonical?: boolean\n}\n\n/**\n * The batched keyset read surface {@link enumeratePaths} / {@link getCanonicalPaths}\n * need. NARROWER than Task 1's point-lookup `PathIndexReader` (which only orders\n * by `updatedAt` and takes plain-value where): enumeration needs `id`-ordering,\n * a `{ gt }` cursor, and `{ in }` batching. Construct the real client directly\n * (`toolkitPathsTable.makeClient(db)`) — it satisfies this type.\n */\nexport type PathEnumReader = {\n findMany(options: {\n where?: PathEnumWhere\n orderBy?: Array<{ column: 'id'; dir?: 'asc' | 'desc' }>\n limit?: number\n }): Promise<PathEnumRow[]>\n}\n\n/**\n * Batch publish check: return the SUBSET of `entityIds` that is published in\n * `locale`. Called ONCE per entity-type group per page. The concrete\n * implementation (see `resolve-context.ts`) runs an elevated-context\n * `QueryClient.findMany({ where: inArray(getTable().id, ids), locale })` — so no\n * unpublished/trashed id comes back. Since D017 the publish filter is\n * CONDITIONAL (it holds for any principal without `view_unpublished`); what\n * keeps that true here is that the elevated context is a REACH context which\n * withholds that action, not that the filter is unconditional.\n */\nexport type PublishFilter = (\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n) => Promise<ReadonlySet<string>>\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** One enumerated path row (phase 05 §2 — exact shape). */\nexport interface PathEntry {\n path: string\n entityType: string\n id: string\n updatedAt: Date\n}\n\n/** Options for {@link enumeratePaths} (phase 05 §2 — exact shape). */\nexport interface EnumeratePathsOptions {\n /** Enumerate this locale's canonical rows only. */\n locale: string\n /** Optionally narrow to a single routable entity type. */\n entityType?: string\n /**\n * Resume the keyset AFTER this `toolkit_paths` row id (exclusive); omit to\n * start from the beginning. The value is the opaque `toolkit_paths` PK the\n * internal page cursor advances on — NOT the yielded `PathEntry.id`, which is\n * the DOCUMENT (entity) id. A cross-request paginating caller (e.g. the\n * content-api `_paths` endpoint) resolves the last consumed entry's row PK and\n * passes it here to continue exactly where the prior page stopped, with no row\n * skipped or repeated. Fully backward-compatible: existing callers omit it and\n * traverse from the start unchanged.\n */\n afterId?: string\n}\n\n/** Injected dependencies for {@link enumeratePaths}. */\nexport interface EnumeratePathsDeps {\n pathsClient: PathEnumReader\n filterPublished: PublishFilter\n}\n\n/** Internal tuning seam (page size / batch concurrency). Real callers omit it. */\nexport interface EnumeratePathsTuning {\n /** Rows per index page. Defaults to {@link ENUMERATE_PAGE_SIZE}. Tests force a small value. */\n pageSize?: number\n /** Peak concurrent publish-check groups. Defaults to {@link PUBLISH_CHECK_CONCURRENCY}. */\n publishConcurrency?: number\n}\n\n/**\n * Index page size — mirrors the rebuild job's `REBUILD_PAGE_SIZE`. Must stay\n * ≤ the `defineTable` `MAX_LIMIT` (1000), which THROWS rather than clamps.\n */\nconst ENUMERATE_PAGE_SIZE = 200\n\n/**\n * Peak concurrent publish-check groups per page. A page holds a handful of\n * distinct entity types at most, so this bound is rarely reached — it exists so\n * the fan-out is EXPLICITLY capped (CLAUDE.md), never `Promise.all` over an\n * unbounded set.\n */\nconst PUBLISH_CHECK_CONCURRENCY = 5\n\n/**\n * Stream the published canonical paths for `opts.locale` (optionally narrowed to\n * `opts.entityType`), keyset-paginated with a per-page batched publish filter.\n * See the module header for the security + fan-out guarantees.\n */\nexport async function* enumeratePaths(\n opts: EnumeratePathsOptions,\n deps: EnumeratePathsDeps,\n tuning: EnumeratePathsTuning = {},\n): AsyncGenerator<PathEntry> {\n const pageSize = tuning.pageSize ?? ENUMERATE_PAGE_SIZE\n const concurrency = tuning.publishConcurrency ?? PUBLISH_CHECK_CONCURRENCY\n const { locale, entityType } = opts\n\n // Seed from `opts.afterId` so a caller can resume mid-stream; undefined starts\n // at the beginning (every existing caller's behavior, unchanged).\n let cursorId: string | undefined = opts.afterId\n for (;;) {\n // Keyset cursor on the immutable `id` PK (the rebuild job's precedent):\n // visits every canonical row exactly once even under concurrent mutation,\n // which OFFSET paging cannot guarantee.\n const where: PathEnumWhere = { locale, isCanonical: true }\n if (entityType !== undefined) where.entityType = entityType\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.pathsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Group this page's rows by entity type (bounded: the routable-type set is\n // small + config-fixed), then ONE publish check per group.\n const byType = new Map<string, PathEnumRow[]>()\n for (const row of page) {\n const bucket = byType.get(row.entityType)\n if (bucket) bucket.push(row)\n else byType.set(row.entityType, [row])\n }\n\n const publishedByType = new Map<string, ReadonlySet<string>>()\n await mapBounded([...byType.entries()], concurrency, async ([type, rows]) => {\n const published = await deps.filterPublished(\n type,\n rows.map((r) => r.entityId),\n locale,\n )\n publishedByType.set(type, published)\n })\n\n // Yield only the page's PUBLISHED rows, preserving the id-ascending order.\n for (const row of page) {\n if (publishedByType.get(row.entityType)?.has(row.entityId)) {\n yield { path: row.path, entityType: row.entityType, id: row.entityId, updatedAt: row.updatedAt }\n }\n }\n\n // Advance the cursor to the last (max-id) row of this raw page. `page` is\n // non-empty here (length-0 broke above). Publish-filtering is a POST-filter,\n // so the cursor always steps over every index row regardless of publish state.\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n// ---------------------------------------------------------------------------\n// getCanonicalPaths — batched forward lookup (search url re-point), NO publish filter\n// ---------------------------------------------------------------------------\n\n/** Deps for {@link getCanonicalPaths}. */\nexport interface CanonicalPathsBatchDeps {\n pathsClient: PathEnumReader\n}\n\n/**\n * Max ids per batched index query — well under `defineTable`'s `MAX_LIMIT`\n * (1000, which throws rather than clamps). Search result pages are far smaller\n * than this, so this is one query in practice; larger id sets chunk safely.\n */\nconst CANONICAL_BATCH_SIZE = 500\n\n/**\n * The current canonical paths for many `(entityType, entityId)` in ONE locale,\n * as an `entityId → path` map — the batched sibling of `getCanonicalPath`.\n *\n * **No publish filter — deliberate**, matching `getCanonicalPath` (and the\n * search url builder it re-points): a search result's url is display-only and\n * the caller already gates row visibility. One index query per {@link\n * CANONICAL_BATCH_SIZE}-sized chunk (one query total for a normal search page),\n * so it never fans out per row.\n */\nexport async function getCanonicalPaths(\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n deps: CanonicalPathsBatchDeps,\n): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n for (let i = 0; i < entityIds.length; i += CANONICAL_BATCH_SIZE) {\n const chunk = entityIds.slice(i, i + CANONICAL_BATCH_SIZE)\n const rows = await deps.pathsClient.findMany({\n where: { entityType, locale, isCanonical: true, entityId: { in: [...chunk] } },\n limit: chunk.length,\n })\n for (const row of rows) result.set(row.entityId, row.path)\n }\n return result\n}\n"],"mappings":"0JA+JA,eAAuB,EACrB,EACA,EACA,EAA+B,CAAC,EACL,CAC3B,IAAM,EAAW,EAAO,UAAY,IAC9B,EAAc,EAAO,oBAAsB,EAC3C,CAAE,SAAQ,cAAe,EAI3B,EAA+B,EAAK,QACxC,OAAS,CAIP,IAAM,EAAuB,CAAE,SAAQ,YAAa,EAAK,EACrD,IAAe,IAAA,KAAW,EAAM,WAAa,GAC7C,IAAa,IAAA,KAAW,EAAM,GAAK,CAAE,GAAI,CAAS,GAEtD,IAAM,EAAO,MAAM,EAAK,YAAY,SAAS,CAC3C,QACA,QAAS,CAAC,CAAE,OAAQ,KAAM,IAAK,KAAM,CAAC,EACtC,MAAO,CACT,CAAC,EACD,GAAI,EAAK,SAAW,EAAG,MAIvB,IAAM,EAAS,IAAI,IACnB,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAS,EAAO,IAAI,EAAI,UAAU,EACpC,EAAQ,EAAO,KAAK,CAAG,EACtB,EAAO,IAAI,EAAI,WAAY,CAAC,CAAG,CAAC,CACvC,CAEA,IAAM,EAAkB,IAAI,IAC5B,MAAM,EAAW,CAAC,GAAG,EAAO,QAAQ,CAAC,EAAG,EAAa,MAAO,CAAC,EAAM,KAAU,CAC3E,IAAM,EAAY,MAAM,EAAK,gBAC3B,EACA,EAAK,IAAK,GAAM,EAAE,QAAQ,EAC1B,CACF,EACA,EAAgB,IAAI,EAAM,CAAS,CACrC,CAAC,EAGD,IAAK,IAAM,KAAO,EACZ,EAAgB,IAAI,EAAI,UAAU,CAAC,EAAE,IAAI,EAAI,QAAQ,IACvD,KAAM,CAAE,KAAM,EAAI,KAAM,WAAY,EAAI,WAAY,GAAI,EAAI,SAAU,UAAW,EAAI,SAAU,GAOnG,IAAM,EAAU,EAAK,EAAK,OAAS,GAGnC,GAFI,CAAC,IACL,EAAW,EAAQ,GACf,EAAK,OAAS,GAAU,KAC9B,CACF,CA4BA,eAAsB,EACpB,EACA,EACA,EACA,EAC8B,CAC9B,IAAM,EAAS,IAAI,IACnB,IAAK,IAAI,EAAI,EAAG,EAAI,EAAU,OAAQ,GAAK,IAAsB,CAC/D,IAAM,EAAQ,EAAU,MAAM,EAAG,EAAI,GAAoB,EACnD,EAAO,MAAM,EAAK,YAAY,SAAS,CAC3C,MAAO,CAAE,aAAY,SAAQ,YAAa,GAAM,SAAU,CAAE,GAAI,CAAC,GAAG,CAAK,CAAE,CAAE,EAC7E,MAAO,EAAM,MACf,CAAC,EACD,IAAK,IAAM,KAAO,EAAM,EAAO,IAAI,EAAI,SAAU,EAAI,IAAI,CAC3D,CACA,OAAO,CACT"}
|
|
1
|
+
{"version":3,"file":"enumerate-CBqu63Kp.mjs","names":[],"sources":["../src/paths/enumerate.ts"],"sourcesContent":["/**\n * Path enumeration for SSG / sitemaps (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2).\n *\n * `enumeratePaths` streams the PUBLISHED canonical `(path, entityType, id,\n * updatedAt)` rows for one locale. It powers `getStaticPaths` + the sitemap\n * helper, so it must NEVER leak an unpublished document (CLAUDE.md #1 — the\n * output feeds public build artifacts).\n *\n * **Security + availability.** Two properties matter here:\n * 1. *No unpublished leak.* `toolkit_paths` carries no publish-status column,\n * so each page's rows are publish-checked against the live entity via the\n * injected {@link EnumeratePathsDeps.filterPublished} — an unpublished (or\n * trashed) document is dropped from the stream.\n * 2. *No unbounded fan-out* (CLAUDE.md \"no amplification\"). Work is bounded by\n * PAGE SIZE, not by total document count: rows are keyset-paginated on the\n * immutable `id` PK (one index query per page), and the publish check is\n * BATCHED — ONE call per distinct entity-type bucket per page (a handful of\n * types at most; the routable set is config-time-fixed, never\n * attacker-influenced), never one call per row.\n *\n * **Leaf module.** Every cross-package dependency (the index reader, the publish\n * filter) is injected via `deps`; this module imports no `@murumets-ee/core` and\n * no `server-only` graph. Concrete deps are built from an app in\n * `./resolve-context.ts` (`buildEnumerateDeps`), keeping the resolution surface\n * off the `lumi` CLI command graph (F027 guardrail).\n *\n * Function-form-path entities need NO special handling — their rows were already\n * materialized into `toolkit_paths` at write time (F004 / phase 05 §2), so a\n * plain index read includes them.\n */\n\nimport { mapBounded } from './apply-path.js'\n\n// ---------------------------------------------------------------------------\n// Read surface (a `type` alias, per index-reader.ts's Deviation-1 rationale, so\n// the real `ToolkitPathsClient` stays assignable to it without any cast).\n// ---------------------------------------------------------------------------\n\n/** The projected `toolkit_paths` row the enumeration + batch lookup read. */\nexport type PathEnumRow = {\n id: string\n entityType: string\n entityId: string\n locale: string\n path: string\n updatedAt: Date\n}\n\n/**\n * The (operator-carrying) where shapes enumeration issues: keyset `id > cursor`,\n * `entityId IN (...)` for the batch lookup, plus plain-value equality filters.\n * A subset of the real client's `ColumnOperators` where-clause, so the real\n * `ToolkitPathsClient` is assignable to {@link PathEnumReader}.\n */\nexport type PathEnumWhere = {\n id?: { gt: string }\n entityType?: string\n entityId?: { in: string[] }\n locale?: string\n path?: string\n isCanonical?: boolean\n}\n\n/**\n * The batched keyset read surface {@link enumeratePaths} / {@link getCanonicalPaths}\n * need. NARROWER than Task 1's point-lookup `PathIndexReader` (which only orders\n * by `updatedAt` and takes plain-value where): enumeration needs `id`-ordering,\n * a `{ gt }` cursor, and `{ in }` batching. Construct the real client directly\n * (`toolkitPathsTable.makeClient(db)`) — it satisfies this type.\n */\nexport type PathEnumReader = {\n findMany(options: {\n where?: PathEnumWhere\n orderBy?: Array<{ column: 'id'; dir?: 'asc' | 'desc' }>\n limit?: number\n }): Promise<PathEnumRow[]>\n}\n\n/**\n * Batch publish check: return the SUBSET of `entityIds` that is published in\n * `locale`. Called ONCE per entity-type group per page. The concrete\n * implementation (see `resolve-context.ts`) runs an elevated-context\n * `QueryClient.findMany({ where: inArray(getTable().id, ids), locale })` — so no\n * unpublished/trashed id comes back. Since D017 the publish filter is\n * CONDITIONAL (it holds for any principal without `view_unpublished`); what\n * keeps that true here is that the elevated context is a REACH context which\n * withholds that action, not that the filter is unconditional.\n */\nexport type PublishFilter = (\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n) => Promise<ReadonlySet<string>>\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** One enumerated path row (phase 05 §2 — exact shape). */\nexport interface PathEntry {\n path: string\n entityType: string\n id: string\n updatedAt: Date\n}\n\n/** Options for {@link enumeratePaths} (phase 05 §2 — exact shape). */\nexport interface EnumeratePathsOptions {\n /** Enumerate this locale's canonical rows only. */\n locale: string\n /** Optionally narrow to a single routable entity type. */\n entityType?: string\n /**\n * Resume the keyset AFTER this `toolkit_paths` row id (exclusive); omit to\n * start from the beginning. The value is the opaque `toolkit_paths` PK the\n * internal page cursor advances on — NOT the yielded `PathEntry.id`, which is\n * the DOCUMENT (entity) id. A cross-request paginating caller (e.g. the\n * content-api `_paths` endpoint) resolves the last consumed entry's row PK and\n * passes it here to continue exactly where the prior page stopped, with no row\n * skipped or repeated. Fully backward-compatible: existing callers omit it and\n * traverse from the start unchanged.\n */\n afterId?: string\n}\n\n/** Injected dependencies for {@link enumeratePaths}. */\nexport interface EnumeratePathsDeps {\n pathsClient: PathEnumReader\n filterPublished: PublishFilter\n}\n\n/** Internal tuning seam (page size / batch concurrency). Real callers omit it. */\nexport interface EnumeratePathsTuning {\n /** Rows per index page. Defaults to {@link ENUMERATE_PAGE_SIZE}. Tests force a small value. */\n pageSize?: number\n /** Peak concurrent publish-check groups. Defaults to {@link PUBLISH_CHECK_CONCURRENCY}. */\n publishConcurrency?: number\n}\n\n/**\n * Index page size — mirrors the rebuild job's `REBUILD_PAGE_SIZE`. Must stay\n * ≤ the `defineTable` `MAX_LIMIT` (1000), which THROWS rather than clamps.\n */\nconst ENUMERATE_PAGE_SIZE = 200\n\n/**\n * Peak concurrent publish-check groups per page. A page holds a handful of\n * distinct entity types at most, so this bound is rarely reached — it exists so\n * the fan-out is EXPLICITLY capped (CLAUDE.md), never `Promise.all` over an\n * unbounded set.\n */\nconst PUBLISH_CHECK_CONCURRENCY = 5\n\n/**\n * Stream the published canonical paths for `opts.locale` (optionally narrowed to\n * `opts.entityType`), keyset-paginated with a per-page batched publish filter.\n * See the module header for the security + fan-out guarantees.\n */\nexport async function* enumeratePaths(\n opts: EnumeratePathsOptions,\n deps: EnumeratePathsDeps,\n tuning: EnumeratePathsTuning = {},\n): AsyncGenerator<PathEntry> {\n const pageSize = tuning.pageSize ?? ENUMERATE_PAGE_SIZE\n const concurrency = tuning.publishConcurrency ?? PUBLISH_CHECK_CONCURRENCY\n const { locale, entityType } = opts\n\n // Seed from `opts.afterId` so a caller can resume mid-stream; undefined starts\n // at the beginning (every existing caller's behavior, unchanged).\n let cursorId: string | undefined = opts.afterId\n for (;;) {\n // Keyset cursor on the immutable `id` PK (the rebuild job's precedent):\n // visits every canonical row exactly once even under concurrent mutation,\n // which OFFSET paging cannot guarantee.\n const where: PathEnumWhere = { locale, isCanonical: true }\n if (entityType !== undefined) where.entityType = entityType\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.pathsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Group this page's rows by entity type (bounded: the routable-type set is\n // small + config-fixed), then ONE publish check per group.\n const byType = new Map<string, PathEnumRow[]>()\n for (const row of page) {\n const bucket = byType.get(row.entityType)\n if (bucket) bucket.push(row)\n else byType.set(row.entityType, [row])\n }\n\n const publishedByType = new Map<string, ReadonlySet<string>>()\n await mapBounded([...byType.entries()], concurrency, async ([type, rows]) => {\n const published = await deps.filterPublished(\n type,\n rows.map((r) => r.entityId),\n locale,\n )\n publishedByType.set(type, published)\n })\n\n // Yield only the page's PUBLISHED rows, preserving the id-ascending order.\n for (const row of page) {\n if (publishedByType.get(row.entityType)?.has(row.entityId)) {\n yield { path: row.path, entityType: row.entityType, id: row.entityId, updatedAt: row.updatedAt }\n }\n }\n\n // Advance the cursor to the last (max-id) row of this raw page. `page` is\n // non-empty here (length-0 broke above). Publish-filtering is a POST-filter,\n // so the cursor always steps over every index row regardless of publish state.\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n// ---------------------------------------------------------------------------\n// getCanonicalPaths — batched forward lookup (search url re-point), NO publish filter\n// ---------------------------------------------------------------------------\n\n/** Deps for {@link getCanonicalPaths}. */\nexport interface CanonicalPathsBatchDeps {\n pathsClient: PathEnumReader\n}\n\n/**\n * Max ids per batched index query — well under `defineTable`'s `MAX_LIMIT`\n * (1000, which throws rather than clamps). Search result pages are far smaller\n * than this, so this is one query in practice; larger id sets chunk safely.\n */\nconst CANONICAL_BATCH_SIZE = 500\n\n/**\n * The current canonical paths for many `(entityType, entityId)` in ONE locale,\n * as an `entityId → path` map — the batched sibling of `getCanonicalPath`.\n *\n * **No publish filter — deliberate**, matching `getCanonicalPath` (and the\n * search url builder it re-points): a search result's url is display-only and\n * the caller already gates row visibility. One index query per {@link\n * CANONICAL_BATCH_SIZE}-sized chunk (one query total for a normal search page),\n * so it never fans out per row.\n */\nexport async function getCanonicalPaths(\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n deps: CanonicalPathsBatchDeps,\n): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n for (let i = 0; i < entityIds.length; i += CANONICAL_BATCH_SIZE) {\n const chunk = entityIds.slice(i, i + CANONICAL_BATCH_SIZE)\n const rows = await deps.pathsClient.findMany({\n where: { entityType, locale, isCanonical: true, entityId: { in: [...chunk] } },\n limit: chunk.length,\n })\n for (const row of rows) result.set(row.entityId, row.path)\n }\n return result\n}\n"],"mappings":"sJA+JA,eAAuB,EACrB,EACA,EACA,EAA+B,CAAC,EACL,CAC3B,IAAM,EAAW,EAAO,UAAY,IAC9B,EAAc,EAAO,oBAAsB,EAC3C,CAAE,SAAQ,cAAe,EAI3B,EAA+B,EAAK,QACxC,OAAS,CAIP,IAAM,EAAuB,CAAE,SAAQ,YAAa,EAAK,EACrD,IAAe,IAAA,KAAW,EAAM,WAAa,GAC7C,IAAa,IAAA,KAAW,EAAM,GAAK,CAAE,GAAI,CAAS,GAEtD,IAAM,EAAO,MAAM,EAAK,YAAY,SAAS,CAC3C,QACA,QAAS,CAAC,CAAE,OAAQ,KAAM,IAAK,KAAM,CAAC,EACtC,MAAO,CACT,CAAC,EACD,GAAI,EAAK,SAAW,EAAG,MAIvB,IAAM,EAAS,IAAI,IACnB,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAS,EAAO,IAAI,EAAI,UAAU,EACpC,EAAQ,EAAO,KAAK,CAAG,EACtB,EAAO,IAAI,EAAI,WAAY,CAAC,CAAG,CAAC,CACvC,CAEA,IAAM,EAAkB,IAAI,IAC5B,MAAM,EAAW,CAAC,GAAG,EAAO,QAAQ,CAAC,EAAG,EAAa,MAAO,CAAC,EAAM,KAAU,CAC3E,IAAM,EAAY,MAAM,EAAK,gBAC3B,EACA,EAAK,IAAK,GAAM,EAAE,QAAQ,EAC1B,CACF,EACA,EAAgB,IAAI,EAAM,CAAS,CACrC,CAAC,EAGD,IAAK,IAAM,KAAO,EACZ,EAAgB,IAAI,EAAI,UAAU,CAAC,EAAE,IAAI,EAAI,QAAQ,IACvD,KAAM,CAAE,KAAM,EAAI,KAAM,WAAY,EAAI,WAAY,GAAI,EAAI,SAAU,UAAW,EAAI,SAAU,GAOnG,IAAM,EAAU,EAAK,EAAK,OAAS,GAGnC,GAFI,CAAC,IACL,EAAW,EAAQ,GACf,EAAK,OAAS,GAAU,KAC9B,CACF,CA4BA,eAAsB,EACpB,EACA,EACA,EACA,EAC8B,CAC9B,IAAM,EAAS,IAAI,IACnB,IAAK,IAAI,EAAI,EAAG,EAAI,EAAU,OAAQ,GAAK,IAAsB,CAC/D,IAAM,EAAQ,EAAU,MAAM,EAAG,EAAI,GAAoB,EACnD,EAAO,MAAM,EAAK,YAAY,SAAS,CAC3C,MAAO,CAAE,aAAY,SAAQ,YAAa,GAAM,SAAU,CAAE,GAAI,CAAC,GAAG,CAAK,CAAE,CAAE,EAC7E,MAAO,EAAM,MACf,CAAC,EACD,IAAK,IAAM,KAAO,EAAM,EAAO,IAAI,EAAI,SAAU,EAAI,IAAI,CAC3D,CACA,OAAO,CACT"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./versionable-Br_G5VGR.mjs";import{r as t}from"./errors-NRNILhhm.mjs";import{i as n,o as r,r as i}from"./routing-strategy-DQGPPBlW.mjs";import{n as a,t as o}from"./content-config-store-8omQF5p5.mjs";import{n as s,t as c}from"./registry-CaPbY5lm.mjs";import"./locale-utils-B0mFoV47.mjs";import{n as l}from"./chrome-entity-names-zYJzP-_U.mjs";import{a as u,n as d,r as f,t as p}from"./schema-D8sBQozw.mjs";import{l as m,t as h,u as g}from"./schema-0zayzpD_.mjs";import{r as _,t as v}from"./routes-C9I1iZw8.mjs";import{n as y}from"./locks-5779ugSW.mjs";import{MAX_PATH_DEPTH as b,hasPathParent as x,resolveEffectiveSlugMode as S,resolvePattern as C}from"@murumets-ee/entity";import{definePlugin as w,getApp as T,getContext as E,runAsCli as D}from"@murumets-ee/core";import{column as O,defineTable as k,schemaRegistry as A}from"@murumets-ee/db";import{asc as j,eq as M,inArray as N,sql as P}from"drizzle-orm";import{z as F}from"zod";import{createHash as I}from"node:crypto";const L=Symbol.for(`@murumets-ee/content:locale-status-writer`);async function R(e,t,n,r){let i=r===`published`?new Date:null;await e.upsert({entityId:t,locale:n,status:r,publishedAt:i},{target:[`entityId`,`locale`],set:{status:r,publishedAt:i}})}function z(e){globalThis[L]=(n,r)=>{let{localeStatus:i}=c(n.name);if(i)return async(n,a,o)=>{let s=E()?.availableLocales??e;if(!s.includes(a))throw new t(a,[...s]);await R(i.makeClient(r),n,a,o)}}}function B(){return{async writeDraft({documentType:e,documentId:t,locale:n,dataPatch:r,user:i,tx:a}){let{drafts:o}=c(e);if(!o)return!1;let s=o.makeClient(a),l=new Date;return await s.upsert({entityId:t,locale:n,data:r,createdBy:i.id,createdByName:i.name??null,createdAt:l,updatedAt:l},{target:[`entityId`,`locale`],set:{updatedAt:l},mergeJsonb:[`data`]}),!0},async readDraft({documentType:e,documentId:t,locale:n,db:r}){let{drafts:i}=c(e);if(!i)return null;let a=await i.makeClient(r).findOne({entityId:t,locale:n});return a?{data:a.data??{},createdBy:a.createdBy??null,createdByName:a.createdByName??null,updatedAt:a.updatedAt??null}:null},async readLocaleStatus({documentType:e,documentId:t,locale:n,db:r}){let{localeStatus:i}=c(e);if(!i)return null;let a=await i.makeClient(r).findOne({entityId:t,locale:n});return a?a.status===`published`?`published`:`draft`:null},async hasAnyDraft({documentType:e,documentId:t,db:n}){let{drafts:r}=c(e);return r?r.makeClient(n).exists({entityId:t}):!1},async hasAnyLocaleActivity({documentType:e,documentId:t,db:n}){let{localeStatus:r}=c(e);if(r&&await r.makeClient(n).exists({entityId:t}))return!0;let i=A.get(`${e}_translations`);if(i){let e=i.entityId;if(e!==void 0&&(await n.select({one:P`1`}).from(i).where(M(e,t)).limit(1)).length>0)return!0}return!1}}}function V(e){let{defaultLocale:t}=o();return e===`_`||e===t?{defaultLocale:t}:{locale:e,defaultLocale:t}}function H(e){let{defaultLocale:t}=o();return e===`_`||e===t?`_`:e}async function U(e,t,n,r){let{drafts:i}=c(e);if(!i)return;let a=await i.makeClient(r).findOne({entityId:t,locale:n});return a?a.data??void 0:void 0}function W(){return{async snapshotCurrentState({documentType:t,documentId:n,locale:r,user:i}){let a=T(),o=a.entities.get(t);if(!o)return!1;let s=e(o.behaviors);if(!s)return!1;let c=a.getClient(o),l=c.getDb(),u=H(r),d=await c.findById(n,V(u));if(!d)return!1;let f=await U(t,n,u,l),p=f?{...d,...f}:{...d},[{createContentContext:m},{createVersionSnapshot:h}]=await Promise.all([import(`./context-Bw0n6P47.mjs`).then(e=>e.t),import(`./versions-BDxB6k_S.mjs`).then(e=>e.c)]);return await h(m({admin:c,entity:o},s),n,p,d,u,{id:i.id,...i.name==null?{}:{name:i.name}}),!0}}}const G=Symbol.for(`@murumets-ee/content:path-writers-factory`),K=Symbol.for(`@murumets-ee/content:cascade-trigger-factory`);function q(e){globalThis[K]=e}function J(){let e=globalThis[K];return typeof e==`function`?e:void 0}function Y(e,t){let n=d.makeClient(e.db.readWrite);return async r=>{if(r.action===`delete`){await n.deleteMany({entityType:r.entityName,entityId:r.id});return}if(r.action===`release`){await n.deleteMany({entityType:r.entityName,entityId:r.id,isCanonical:!0});return}let i=o(),a=i.defaultLocale,s=i.reservedPathPrefixes??[],c=r.locale?[r.locale]:i.locales.map(e=>e.code),l=r.viaCascade===!0?e.getSystemClient(t):e.getClient(t),u=async n=>{if(!x(t))return;let i=J();if(i)try{await i(e,t,r.id,n)}catch(t){e.logger.warn({err:t,entity:r.entityName,id:r.id,locale:n},`toolkit_paths: descendant-cascade trigger threw — the entity write already committed, so tolerating and continuing (paths are rebuildable, D005)`)}};for(let i of c){let o=i===r.locale?r.row??null:await l.findById(r.id,{locale:i,defaultLocale:a});if(!o){await f({pathsClient:n,entityType:r.entityName,entityId:r.id,locale:i,path:null,skipReason:`row-unreadable`,reservedPathPrefixes:s,logger:e.logger});continue}let c=await m({entity:t,row:o,locale:i,defaultLocale:a,pathsClient:n}),d=await f({pathsClient:n,entityType:r.entityName,entityId:r.id,locale:i,path:c.path,...c.skipReason!==void 0&&{skipReason:c.skipReason},reservedPathPrefixes:s,logger:e.logger});(d.outcome===`renamed`||r.action===`restore`&&d.outcome===`created`)&&await u(i)}}}function X(e,t){let n=d.makeClient(e.db.readWrite);return async r=>{let i=o(),a=i.defaultLocale,s=i.reservedPathPrefixes??[],c=r.locale??a,l=null;r.id!==void 0&&(l=await e.getClient(t).findById(r.id,{locale:c,defaultLocale:a}));let u=S(r.data.slugMode,l?.slugMode);await g({entity:t,data:r.data,currentRow:l,id:r.id,locale:c,defaultLocale:a,slugMode:u,reservedPathPrefixes:s,pathsClient:n})}}function ee(){globalThis[G]=(e,t)=>({pathIndexWriter:Y(e,t),pathConflictGuard:X(e,t)})}const Z=F.object({entityType:F.string().min(1),parentId:F.string().uuid(),locale:F.string().min(1)}),Q=F.object({entity:F.string().min(1).optional(),locale:F.string().min(1).optional(),dryRun:F.boolean()});async function te(e,t,n={}){let{entityType:r,parentId:i,locale:a}=t,s=n.maxItems??2e3,c=n.concurrency??5,l={processed:0,regenerated:0,unchanged:0,skipped:0,failed:0,truncated:!1},p=e=>({phase:e,...l}),h=()=>({entityType:r,parentId:i,locale:a,cap:s,...l}),g=e.entities.get(r);if(!g)return e.logger.warn({entityType:r,parentId:i,locale:a},`paths-cascade: unknown entity type — nothing to regenerate`),n.onProgress?.(p(`done`)),h();if(!x(g))return e.logger.warn({entityType:r,parentId:i,locale:a},`paths-cascade: entity is not a pathParent() hierarchy — nothing to regenerate`),n.onProgress?.(p(`done`)),h();let _=o(),v=_.defaultLocale,y=_.reservedPathPrefixes??[],b=d.makeClient(e.db.readWrite),S=e.getClient(g),C=S.getTable().parent,w=async t=>{let n=t.id;try{let i=await m({entity:g,row:t,locale:a,defaultLocale:v,pathsClient:b});switch((await f({pathsClient:b,entityType:r,entityId:n,locale:a,path:i.path,...i.skipReason!==void 0&&{skipReason:i.skipReason},reservedPathPrefixes:y,logger:e.logger})).outcome){case`created`:case`renamed`:l.regenerated+=1;break;case`unchanged`:l.unchanged+=1;break;default:l.skipped+=1;break}}catch(t){l.failed+=1,e.logger.warn({err:t,entityType:r,id:n,locale:a},`paths-cascade: failed to regenerate one descendant path — continuing (per-item isolation)`)}},T=[i];for(;T.length>0;){let e=s-l.processed;if(e<=0){l.truncated=!0;break}let t=await S.findMany({where:N(C,T),locale:a,defaultLocale:v,limit:e+1});if(t.length===0)break;let r=t;if(t.length>e&&(r=t.slice(0,e),l.truncated=!0),await u(r,c,w),l.processed+=r.length,n.onProgress?.(p(`walking`)),l.truncated)break;T=r.map(e=>e.id)}return l.truncated&&e.logger.warn({entityType:r,parentId:i,locale:a,processed:l.processed,cap:s},`paths-cascade: descendant subtree exceeded the cap — deeper/remaining descendants were NOT regenerated. Run \`lumi paths:rebuild --entity ${r}\` for guaranteed full coverage.`),n.onProgress?.(p(`done`)),h()}async function ne(e,t,n={}){let r=n.pageSize??200,i=n.concurrency??5,a=n.sampleCap??25,s=t.dryRun,c=o(),l=c.defaultLocale,p=c.reservedPathPrefixes??[],h=c.locales.map(e=>e.code),g=t.locale?h.filter(e=>e===t.locale):h,_=[...e.entities.values()].filter(e=>!!e.frontend?.path&&(t.entity===void 0||e.name===t.entity)),v=t.entity!==void 0&&_.length===0,y=t.locale!==void 0&&g.length===0;v&&e.logger.warn({entity:t.entity,dryRun:s},`paths-rebuild: --entity matches no routable entity (one declaring frontend.path) — nothing to rebuild (likely a typo in the entity name)`),y&&e.logger.warn({locale:t.locale,configuredLocales:h,dryRun:s},`paths-rebuild: --locale matches no configured locale — nothing to rebuild (likely a typo in the locale code)`);let S=v||y?{entity:v,locale:y}:void 0,C=_.some(e=>x(e)),w=s&&C?`Preview reflects a SINGLE pass; a pathParent() hierarchy drifted at multiple levels may need the real (non-dry-run) rebuild's multi-pass convergence to fully resolve. The real run always converges.`:void 0,T=d.makeClient(e.db.readWrite),E={scanned:0,changed:0,applied:0,failed:0},D=new Map,O={missing:0,conflicts:0,unchanged:0,skipped:0},k=(e,t)=>{let n=D.get(e);n!==t&&(n!==void 0&&n!==`resolved`&&--O[n],D.set(e,t),t!==`resolved`&&(O[t]+=1))},A=[],M=!1,N=e=>{A.length<a?A.push(e):M=!0},P=0,F=e=>({phase:e,dryRun:s,scope:{entity:t.entity??null,locale:t.locale??null},scanned:E.scanned,changed:E.changed,applied:E.applied,failed:E.failed,missing:O.missing,conflicts:O.conflicts,unchanged:O.unchanged,skipped:O.skipped,passes:P,sample:A,sampleTruncated:M,...w===void 0?{}:{hierarchyNote:w},...S===void 0?{}:{unmatchedScope:S}}),I=async(t,n,r,i)=>{let a=r.id,o=!1;try{let c=await m({entity:t,row:r,locale:i,defaultLocale:l,pathsClient:T}),u=await f({pathsClient:T,entityType:n,entityId:a,locale:i,path:c.path,...c.skipReason!==void 0&&{skipReason:c.skipReason},suppressNoteworthy:[`parent-not-indexed`],reservedPathPrefixes:p,logger:e.logger,dryRun:s}),d=`${n}:${a}:${i}`;switch(u.outcome){case`unchanged`:k(d,`unchanged`);break;case`skipped-null`:case`skipped-reserved`:k(d,`skipped`);break;case`conflict`:k(d,`conflicts`),N({kind:`conflict`,entityType:n,entityId:a,locale:i,oldPath:u.oldPath,newPath:u.newPath,...u.conflictWith?{conflictWith:u.conflictWith.entityId}:{}});break;case`created`:s?k(d,`missing`):(E.applied+=1,o=!0,k(d,`resolved`)),N({kind:`missing`,entityType:n,entityId:a,locale:i,oldPath:null,newPath:u.newPath});break;case`renamed`:E.changed+=1,s||(E.applied+=1,o=!0),k(d,`resolved`),N({kind:`changed`,entityType:n,entityId:a,locale:i,oldPath:u.oldPath,newPath:u.newPath});break}}catch(t){E.failed+=1,e.logger.warn({err:t,entityType:n,id:a,locale:i},`paths-rebuild: failed to process one document — continuing (per-item isolation)`)}return o},L=async t=>{let a=0;for(let o of t){let t=e.getClient(o),s=o.name,c=t.getTable().id;for(let e of g){let d;for(;;){let f=await t.findMany({locale:e,defaultLocale:l,limit:r,orderBy:j(c),...d===void 0?{}:{cursor:{field:`id`,value:d,direction:`asc`}}});if(f.length===0)break;await u(f,i,async t=>{await I(o,s,t,e)&&(a+=1)}),E.scanned+=f.length;let p=f[f.length-1];if(p&&(d=p.id),n.onProgress?.(F(`scanning`)),f.length<r)break}}}return a},R=async t=>{let r=[];for(let[n,i]of D){if(i!==`conflicts`&&i!==`skipped`&&i!==`missing`)continue;let a=n.indexOf(`:`),o=n.lastIndexOf(`:`);if(a<=0||o<=a)continue;let s=n.slice(0,a);if(t.has(s))continue;let c=e.entities.get(s);c!==void 0&&r.push({entity:c,entityType:s,entityId:n.slice(a+1,o),locale:n.slice(o+1)})}if(r.length===0)return 0;let a=0;return await u(r,i,async t=>{let n=await e.getClient(t.entity).findById(t.entityId,{locale:t.locale,defaultLocale:l});n!==null&&(E.scanned+=1,await I(t.entity,t.entityType,n,t.locale)&&(a+=1))}),n.onProgress?.(F(`scanning`)),a},z=_.filter(e=>x(e)),B=new Set(z.map(e=>e.name)),V=s||!C?1:b;for(let e=0;e<V;e++){P=e+1;let t=await L(e===0?_:z);if(e>0&&(t+=await R(B)),t===0)break}M&&e.logger.warn({scanned:E.scanned,changed:E.changed,missing:O.missing,conflicts:O.conflicts,sampleCap:a},`paths-rebuild: more diff entries than the progress sample can hold — see the aggregate counts; the stored sample is truncated`);let H=F(`done`);n.onProgress?.(H);let{phase:U,...W}=H;return W}async function re(e){let{defineJob:t,registerJob:n,QueueClient:r}=await import(`@murumets-ee/queue/client`),i=t({name:`content:paths-cascade`,description:`Regenerate toolkit_paths canonical rows for the descendants of a renamed routable-hierarchy ancestor (one locale). Walks the full bounded subtree in a single invocation.`,schema:Z}),a=t({name:`content:paths-rebuild`,description:`Recompute every routable document's canonical toolkit_paths row (optionally scoped to one entity/locale); dryRun reports the diff without writing. On-demand (lumi paths:rebuild).`,schema:Q});n(i,async t=>{await D(async()=>{let n=await te(e,Z.parse(t.payload),{onProgress:e=>t.updateProgress(e)});e.logger.info(n,`paths-cascade: descendant regeneration complete`)})}),n(a,async t=>{await D(async()=>{let n=await ne(e,Q.parse(t.payload),{onProgress:e=>t.updateProgress(e)});e.logger.info({dryRun:n.dryRun,scanned:n.scanned,changed:n.changed,missing:n.missing,conflicts:n.conflicts,applied:n.applied,failed:n.failed,passes:n.passes,...n.hierarchyNote===void 0?{}:{hierarchyNote:n.hierarchyNote}},`paths-rebuild: sweep complete`)})}),q(async(e,t,n,a)=>{await new r({app:e,logger:e.logger}).enqueue(i,{entityType:t.name,parentId:n,locale:a})})}const $=k({name:`toolkit_path_patterns`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityType:O.varchar({length:100,notNull:!0,pgName:`entity_type`}),locale:O.varchar({length:10,notNull:!0}),patternHash:O.varchar({length:64,notNull:!0,pgName:`pattern_hash`}),updatedAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityType`,`locale`]}]}),ie=$.table;function ae(e,t,n){let r=C(e,t,n);if(r===null)return null;let i=typeof r==`function`?r.toString():JSON.stringify(r);return I(`sha256`).update(i).digest(`hex`)}async function oe(e){let t=o(),n=t.defaultLocale,r=t.locales.map(e=>e.code),i=$.makeClient(e.db.readWrite);for(let[t,a]of e.entities){let o=a.frontend?.path;if(o!==void 0)for(let a of r){let r=ae(o,a,n);if(r===null)continue;let s=await i.findOne({entityType:t,locale:a});s!==null&&s.patternHash===r||(s!==null&&e.logger.warn({entity:t,locale:a,previousHash:s.patternHash,currentHash:r},`toolkit_paths: frontend.path pattern for '${t}' (locale '${a}') changed since last boot — stale toolkit_paths rows may exist; run \`lumi paths:rebuild --entity ${t}\``),await i.upsert({entityType:t,locale:a,patternHash:r},{target:[`entityType`,`locale`],set:{patternHash:r,updatedAt:new Date}}))}}}const se=async(e,t,n)=>{if(t.length===0)return new Map;let[{buildEnumerateDeps:r},{getCanonicalPaths:i}]=await Promise.all([import(`./resolve-context-MRZZ3rQl.mjs`).then(e=>e.l),import(`./enumerate-CBqu63Kp.mjs`).then(e=>e.n)]),a=r(T()),o=await a.filterPublished(e,t,n);return o.size===0?new Map:i(e,[...o],n,a)};function ce(e){return k({name:`${e.name}_drafts`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityId:O.uuid({notNull:!0,pgName:`entity_id`}),locale:O.varchar({length:10,notNull:!0,default:`_`}),data:O.jsonb({notNull:!0}),createdBy:O.varchar({length:255,notNull:!0,pgName:`created_by`}),createdByName:O.varchar({length:255,pgName:`created_by_name`}),createdAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityId`,`locale`]}]})}function le(e){return k({name:`${e.name}_locale_status`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityId:O.uuid({notNull:!0,pgName:`entity_id`}),locale:O.varchar({length:10,notNull:!0}),status:O.varchar({length:20,notNull:!0,default:`draft`}),publishedAt:O.timestamp({withTimezone:!0,pgName:`published_at`})},unique:[{on:[`entityId`,`locale`]}]})}function ue(e){return k({name:`${e.name}_versions`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityId:O.uuid({notNull:!0,pgName:`entity_id`}),version:O.integer({notNull:!0}),locale:O.varchar({length:10,notNull:!0,default:`_`}),data:O.jsonb({notNull:!0}),delta:O.jsonb(),status:O.varchar({length:20}),createdBy:O.varchar({length:255,pgName:`created_by`}),createdByName:O.varchar({length:255,pgName:`created_by_name`}),createdAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),isAutosave:O.boolean({notNull:!0,default:!1,pgName:`is_autosave`})},unique:[{on:[`entityId`,`version`,`locale`]}]})}function de(e){if(!e.locales||e.locales.length===0)throw Error(`content() requires at least one locale`);if(!e.defaultLocale)throw Error(`content() requires a defaultLocale`);if(!e.locales.some(t=>t.code===e.defaultLocale))throw Error(`defaultLocale "${e.defaultLocale}" not found in locales: [${e.locales.map(e=>e.code).join(`, `)}]`);if(e.reservedPathPrefixes){for(let t of e.reservedPathPrefixes)if(typeof t!=`string`||!t.startsWith(`/`))throw Error(`content(): reservedPathPrefixes entries must be absolute paths starting with '/', got ${JSON.stringify(t)}`)}let t=e.autoTrash??!0,o=t===!1?void 0:{publishable:!0,...typeof t==`object`&&t.exclude?{exclude:t.exclude}:{}};return w({name:`@murumets-ee/content`,contributes:{"blocks.documentDraftStore":B(),"blocks.documentVersionStore":W(),"blocks.publishedPathResolver":se,...o?{"trash.autoEnable":o}:{},...e.reservedPathPrefixes?{"routing.reservedPathPrefixes":e.reservedPathPrefixes}:{}},shared:{settings:[r],features:[{resource:`redirect`,actions:[`manage`],defaultRoles:[`admin`],description:`Create, edit, and delete manual URL redirects (toolkit_redirects).`},{resource:`path`,actions:[`resolve`],defaultRoles:[`admin`,`editor`],description:`Resolve a site URL path to the CMS document that serves it.`}]},server:{entities:[n,i],tables:{toolkitContentLocks:y,toolkitPaths:p,toolkitPathPatterns:ie,toolkitRedirects:h},routes:[...v(),..._()],seeders:[{name:`content:site-chrome`,description:`Create the empty site_chrome row for each placement (header, footer), as a DRAFT, so they can be edited on the page canvas. Publishing one is what makes /v1/_chrome serve it instead of the delivery template's own fallback. Idempotent — an existing row for a placement is left alone.`,available:l,bootstrap:!0,run:async()=>{let{seedSiteChrome:e}=await import(`@murumets-ee/content/seed`);return await e()}}],init:async t=>{a(e);let n=0,r=0,i=0;for(let[e,a]of t.entities){if(a.behaviors?.some(e=>e.name===`versionable`)){let t=`${e}_versions`;if(!A.has(t)){let n=ue(a);A.register(t,n.table),s(e,{versions:n})}n++}let t=a.behaviors?.some(e=>e.name===`publishable`),o=a.allFields,c=Object.values(o).some(e=>e.translatable);if(t&&c){let t=`${e}_locale_status`;if(!A.has(t)){let n=le(a);A.register(t,n.table),s(e,{localeStatus:n})}r++}if(t){let t=`${e}_drafts`;if(!A.has(t)){let n=ce(a);A.register(t,n.table),s(e,{drafts:n})}i++}}z(e.locales.map(e=>e.code)),ee();try{await re(t)}catch(e){t.logger.warn({err:e},"content: failed to register routing path jobs — descendant cascade + `lumi paths:rebuild` will be unavailable (path writes still succeed)")}t.logger.info({locales:e.locales.map(e=>e.code),defaultLocale:e.defaultLocale,versionableEntities:n,localeStatusEntities:r,draftEntities:i},`Content plugin initialized`)},onReady:async e=>{try{await oe(e)}catch(t){e.logger.error({err:t},"content: boot-time frontend-path pattern-drift check failed — continuing app boot without it. Inspect the logged `err` for the cause: a transient DB error can be retried (and `lumi paths:rebuild` re-derives the index if a pattern actually changed), whereas a structural cause — e.g. a missing migration leaving `toolkit_path_patterns` absent — will recur on every boot and needs `lumi migrate`")}}}})}export{R as n,de as t};
|
|
2
|
+
//# sourceMappingURL=plugin-BxOHoKPR.mjs.map
|