@murumets-ee/content 0.27.0 → 0.29.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/client.d.mts +2 -2
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/entities/index.d.mts +1 -1
- package/dist/entities/index.d.mts.map +1 -1
- package/dist/entities/index.mjs +1 -1
- package/dist/entities/index.mjs.map +1 -1
- package/dist/{errors-DLvszZ9R.mjs → errors-bZAGPYhp.mjs} +1 -1
- package/dist/{errors-DLvszZ9R.mjs.map → errors-bZAGPYhp.mjs.map} +1 -1
- package/dist/{index-C9EWajwm.d.mts → index-C4hbhXsu.d.mts} +3 -44
- package/dist/index-C4hbhXsu.d.mts.map +1 -0
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/locale-utils-w0WO6J_P.mjs +2 -0
- package/dist/{locale-utils-q7qYD-ff.mjs.map → locale-utils-w0WO6J_P.mjs.map} +1 -1
- package/dist/lock-service.d.mts +1 -1
- package/dist/lock-service.d.mts.map +1 -1
- package/dist/lock-service.mjs +1 -1
- package/dist/lock-service.mjs.map +1 -1
- package/dist/{locks-BRtmYqgS.mjs → locks-5779ugSW.mjs} +1 -1
- package/dist/{locks-BRtmYqgS.mjs.map → locks-5779ugSW.mjs.map} +1 -1
- package/dist/{plugin-ppoIGq-c.mjs → plugin-CG2tNeoG.mjs} +2 -2
- package/dist/{plugin-ppoIGq-c.mjs.map → plugin-CG2tNeoG.mjs.map} +1 -1
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/query-client.d.mts.map +1 -1
- package/dist/query-client.mjs +1 -1
- package/dist/query-client.mjs.map +1 -1
- package/dist/types-CRnsF2Eb.d.mts +43 -0
- package/dist/types-CRnsF2Eb.d.mts.map +1 -0
- package/dist/{types-BhMSxasE.d.mts → types-yRLOOCOn.d.mts} +17 -1
- package/dist/types-yRLOOCOn.d.mts.map +1 -0
- package/dist/{versionable-B0bP2Gib.mjs → versionable-Br_G5VGR.mjs} +1 -1
- package/dist/{versionable-B0bP2Gib.mjs.map → versionable-Br_G5VGR.mjs.map} +1 -1
- package/dist/{workflowable-aGtQhPRc.mjs → workflowable-BufrZXu9.mjs} +1 -1
- package/dist/{workflowable-aGtQhPRc.mjs.map → workflowable-BufrZXu9.mjs.map} +1 -1
- package/package.json +8 -8
- package/dist/index-C9EWajwm.d.mts.map +0 -1
- package/dist/locale-utils-q7qYD-ff.mjs +0 -2
- package/dist/types-BhMSxasE.d.mts.map +0 -1
package/dist/client.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as TranslationStatus, i as LocalePublishStatus, l as VersionEntry, n as DraftEntry, s as TranslationReport } from "./types-
|
|
2
|
-
import {
|
|
1
|
+
import { c as TranslationStatus, i as LocalePublishStatus, l as VersionEntry, n as DraftEntry, s as TranslationReport } from "./types-yRLOOCOn.mjs";
|
|
2
|
+
import { i as FieldDiff } from "./types-CRnsF2Eb.mjs";
|
|
3
3
|
import { Entity, FieldConfig } from "@murumets-ee/entity";
|
|
4
4
|
import { Logger } from "@murumets-ee/core";
|
|
5
5
|
import { AdminClient, FindManyOptions, InferCreateInput, InferEntityDTO, InferUpdateInput } from "@murumets-ee/entity/admin";
|
package/dist/client.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./versionable-B0bP2Gib.mjs";import{n as t,t as n}from"./errors-DLvszZ9R.mjs";import{i as r,n as i,r as a}from"./plugin-ppoIGq-c.mjs";import{t as o}from"./locale-utils-q7qYD-ff.mjs";import"server-only";import{getApp as s}from"@murumets-ee/core";import{createAdminClient as c}from"@murumets-ee/core/clients";import{schemaRegistry as l}from"@murumets-ee/db";import{eq as u,inArray as d}from"drizzle-orm";function f(e){return e.match(/\S+|\s+/g)??[]}function p(e,t){if(e===t)return e?[{type:`equal`,value:e}]:[];if(!e)return[{type:`add`,value:t}];if(!t)return[{type:`remove`,value:e}];let n=f(e),r=f(t),i=n.length,a=r.length,o=Array.from({length:i+1},()=>Array(a+1).fill(0));for(let e=1;e<=i;e++){let t=o[e],i=o[e-1];if(!(!t||!i))for(let o=1;o<=a;o++)n[e-1]===r[o-1]?t[o]=(i[o-1]??0)+1:t[o]=Math.max(i[o]??0,t[o-1]??0)}let s=[],c=i,l=a,u=[];for(;c>0||l>0;){let e=c>0?n[c-1]:void 0,t=l>0?r[l-1]:void 0;if(c>0&&l>0&&e!==void 0&&e===t)u.push({type:`equal`,value:e}),c--,l--;else if(l>0&&t!==void 0&&(c===0||(o[c]?.[l-1]??0)>=(o[c-1]?.[l]??0)))u.push({type:`add`,value:t}),l--;else if(e!==void 0)u.push({type:`remove`,value:e}),c--;else break}u.reverse();for(let e of u){let t=s[s.length-1];t&&t.type===e.type?t.value+=e.value:s.push({...e})}return s}function m(e){return e.replace(/<[^>]*>/g,` `).replace(/\s+/g,` `).trim()}function h(e){return e==null?``:e instanceof Date?e.toISOString():typeof e==`object`?JSON.stringify(e):String(e)}function g(e,t,n,r){if(JSON.stringify(t)===JSON.stringify(n))return null;switch(r?.type??v(t,n)){case`text`:case`slug`:return{field:e,type:`text`,from:t,to:n,segments:p(h(t),h(n))};case`richtext`:return{field:e,type:`richtext`,from:t,to:n,segments:p(m(h(t)),m(h(n)))};case`number`:return{field:e,type:`number`,from:t,to:n};case`boolean`:return{field:e,type:`boolean`,from:t,to:n};case`date`:return{field:e,type:`date`,from:t,to:n};case`select`:return{field:e,type:`select`,from:t,to:n};case`media`:return{field:e,type:`media`,from:t,to:n};case`json`:return{field:e,type:`json`,from:t,to:n};case`blockTree`:return{field:e,type:`blockTree`,from:t,to:n};default:return{field:e,type:`unknown`,from:t,to:n}}}function _(e,t,n){let r=new Set([...Object.keys(e),...Object.keys(t)]),i=[];for(let a of r){if(a===`id`||a===`createdAt`||a===`updatedAt`)continue;let r=g(a,e[a],t[a],n?.[a]);r&&i.push(r)}return i}function v(e,t){let n=e??t;if(typeof n==`number`)return`number`;if(typeof n==`boolean`)return`boolean`;if(n instanceof Date)return`date`;if(typeof n==`string`)return/^\d{4}-\d{2}-\d{2}/.test(n)?`date`:`text`;if(Array.isArray(n)){let e=n[0];return n.length>0&&typeof e==`object`&&e&&`type`in e&&`id`in e?`blockTree`:`json`}return typeof n==`object`?`json`:`unknown`}function y(e,t){if(typeof e==`string`||typeof t==`string`)return p(h(e),h(t))}function b(e){if(typeof e!=`object`||!e)return null;let t=e;if(typeof t.id!=`string`||typeof t.type!=`string`)return null;let n={...t.props??{}};return t.traits!==void 0&&(n.traits=t.traits),t.children!==void 0&&(n.children=t.children),{id:t.id,type:t.type,props:n}}function x(e,t){let n=new Set([...Object.keys(e),...Object.keys(t)]),r=[];for(let i of n){let n=e[i],a=t[i];JSON.stringify(n)!==JSON.stringify(a)&&r.push({field:i,from:n,to:a,segments:y(n,a)})}return r}function S(e){let t=new Map,n=0;for(let r of e){let e=b(r);e&&(t.set(e.id,{block:e,index:n}),n++)}return t}function C(e,t){let n=S(e),r=S(t),i=[],a=new Set;for(let[e,{block:t,index:o}]of r){a.add(e);let r=n.get(e);if(!r){i.push({blockId:e,blockType:t.type,status:`added`,toIndex:o});continue}let s=r.block,c=r.index,l=x(s.props,t.props),u=c!==o;l.length===0&&!u?i.push({blockId:e,blockType:t.type,status:`unchanged`,fromIndex:c,toIndex:o}):l.length>0?i.push({blockId:e,blockType:t.type,status:`changed`,fromIndex:c,toIndex:o,propDiffs:l}):i.push({blockId:e,blockType:t.type,status:`moved`,fromIndex:c,toIndex:o})}for(let[e,{block:t,index:r}]of n)a.has(e)||i.push({blockId:e,blockType:t.type,status:`removed`,fromIndex:r});return i}function w(e,t){return{entity:e.entity,admin:e.admin,db:e.admin.getDb(),versionableOptions:t,logger:e.logger}}function T(e){let{drafts:t}=r(e.entity.name);return t?t.makeClient(e.db):null}function E(e){let{versions:t}=r(e.entity.name);return t?t.makeClient(e.db):null}function D(e){let{localeStatus:t}=r(e.entity.name);return t?t.makeClient(e.db):null}async function O(e,t){let n=(await A(e,[t])).get(t);return n?Object.values(n):[]}async function k(e,n,r,s){if(o(r),r===i().defaultLocale){let t={status:s};s===`published`&&(t.publishedAt=new Date),await e.admin.update(n,t);return}let c=D(e);if(!c)throw new t(e.entity.name,`localeStatus`);await a(c,n,r,s)}async function A(e,t){let n=new Map;if(t.length===0)return n;let r=D(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 ee(e){return e===`_`||e===i().defaultLocale}function j(e){let{defaultLocale:t}=i();return ee(e)?{defaultLocale:t}:{locale:e,defaultLocale:t}}async function M(e,t,n,r){let a=D(e)!==null;return e.admin.updateForLocale(t,n,r,{defaultLocale:i().defaultLocale,...a&&{localeStatusWriter:(t,n,r)=>k(e,t,n,r)}})}const N=new Set([`id`,`createdAt`,`createdBy`,`updatedAt`,`updatedBy`]);function P(e,t){let n=e.allFields,r={};for(let e of Object.keys(t)){if(N.has(e)||e.startsWith(`_`)||!Object.hasOwn(n,e))continue;let i=n[e];i&&i.internal===!0||(r[e]=t[e])}return r}function te(e,t){return JSON.stringify(e)===JSON.stringify(t)}function F(e,t){let n={};for(let r of Object.keys(t))r===`id`||r.startsWith(`_`)||te(e[r],t[r])||(n[r]=t[r]);return n}async function I(e,t,n){n?.locale!==void 0&&o(n.locale);let r=E(e);return r?await r.findMany({where:{entityId:t,locale:n?.locale??`_`},orderBy:[{column:`version`,dir:`desc`}],...n?.limit===void 0?{}:{limit:n.limit},...n?.offset===void 0?{}:{offset:n.offset}}):[]}async function L(e,t,n,r=`_`){o(r);let i=E(e);return i?await i.findOne({entityId:t,version:n,locale:r})??null:null}async function R(e,t,n,r,i=`_`){o(i);let[a,s]=await Promise.all([L(e,t,n,i),L(e,t,r,i)]);if(!a||!s)return null;let c=e.entity.allFields,l=_(a.data,s.data,c);for(let e of l)e.type===`blockTree`&&(e.blockDiffs=C(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:l,fromVersion:a,toVersion:s}}async function z(e,n,r,i=`_`){if(o(i),!E(e))throw new t(e.entity.name,`versions`);let a=await L(e,n,r,i);if(!a)throw Error(`Version ${r} not found for entity ${n}`);let s=a.data;return M(e,n,P(e.entity,s),i)}async function B(e,t,n=`_`){if(o(n),!e.versionableOptions)return;let r=E(e);if(!r)return;let i=e.versionableOptions.maxPerDoc,a=await r.findMany({where:{entityId:t,locale:n},orderBy:[{column:`version`,dir:`desc`}],limit:i});if(a.length<i)return;let s=a.map(e=>e.id);await r.deleteMany({entityId:t,locale:n,id:{notIn:s}})}async function V(e,t,n,r,i,a=`_`,s){o(a);let c=E(e);if(!c)return;let[l]=await c.aggregate({select:{maxVersion:{fn:`max`,column:`version`}},where:{entityId:t,locale:a}}),u=Number(l?.maxVersion??0)+1,d=n.status??null;if(i){let e=await c.findOne({entityId:t,isAutosave:!0,version:u,locale:a});if(e)return await c.update({id:e.id},{data:n,delta:r,status:d,createdAt:new Date}),u}return await c.insert({entityId:t,version:u,locale:a,data:n,delta:r,status:d,createdBy:s?.id??null,createdByName:s?.name??null,isAutosave:i}),u}async function H(e,t,n,r,i,a){if(i!==void 0&&o(i),!e.versionableOptions)return;let s=i??`_`,c=await V(e,t,n,r?F(r,n):null,!1,s,a);c!==void 0&&s===`_`&&await U(e,t,c),await B(e,t,s)}async function U(e,t,n){let r=l.get(e.entity.name);r&&await e.db.update(r).set({_version:n}).where(u(r.id,t))}function W(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 G(e,n,r,i=`_`,a){o(i);let s=T(e);if(!s)throw new t(e.entity.name,`drafts`);let c=new Date,l=a?.id??``,u=a?.name??null;return W(await s.upsert({entityId:n,locale:i,data:r,createdBy:l,createdByName:u,createdAt:c,updatedAt:c},{target:[`entityId`,`locale`],set:{data:r,updatedAt:c}}))}async function K(e,t,n=`_`){o(n);let r=T(e);if(!r)return null;let i=await r.findOne({entityId:t,locale:n});return i?W(i):null}async function q(e,t,n=`_`){o(n);let r=T(e);return r?(await r.deleteMany({entityId:t,locale:n})).length>0:!1}async function J(e,r,i=`_`,a){if(o(i),!T(e))throw new t(e.entity.name,`drafts`);let s=await K(e,r,i);if(!s)throw new n(r,i);let c=null;e.versionableOptions&&(c=await e.admin.findById(r,j(i)));let l=s.data,u=l.status===void 0?{...l,status:`published`}:l,d=await M(e,r,P(e.entity,u),i);return await q(e,r,i),e.versionableOptions&&await H(e,r,d,c,i,a),d}async function Y(e,t,n=`_`){o(n);let r=T(e);return r?r.exists({entityId:t,locale:n}):!1}async function X(e,t=`_`){o(t);let n=T(e);return n?n.count({locale:t}):0}async function Z(e,t,n=`_`){o(n);let r=new Set;if(t.length===0)return r;let i=T(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 ne(e,t,n=`_`){o(n);let r=await K(e,t,n);if(!r)return null;let a=i().defaultLocale,s=n===`_`?{defaultLocale:a}:{locale:n,defaultLocale:a},c=await e.admin.findById(t,s);if(!c)return null;let l=e.entity.allFields,u=c,d=r.data,f={};for(let e of Object.keys(d))f[e]=u[e];let p=_(f,d,l);for(let e of p)e.type===`blockTree`&&(e.blockDiffs=C(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:p,current:c,draft:r}}function re(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 ie(e,t){return(await Q(e,[t])).get(t)??{}}async function Q(e,t){let n=new Map;if(t.length===0)return n;let r=i(),a=Object.entries(e.entity.allFields).filter(([e,t])=>t.translatable).map(([e])=>e);if(a.length===0)return n;let o=l.get(`${e.entity.name}_translations`);if(!o)return n;let s=await e.db.select().from(o).where(d(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,re(a,c.get(e),r.locales,r.defaultLocale));return n}async function ae(e,t){let n=i(),r=t?.locale??n.locales.find(e=>e.code!==n.defaultLocale)?.code;if(!r)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 Q(e,a.map(e=>e.id)),s=0,c=0,l=0;for(let e of a){let t=o.get(e.id)?.[r];!t||t.translated===0?l++:t.complete?s++:c++}return{total:a.length,complete:s,partial:c,missing:l}}var $=class{ctx;constructor(t){let n=e(t.entity.behaviors);this.ctx=w(t,n)}async create(e){let t=await this.ctx.admin.create(e);return this.ctx.versionableOptions&&await V(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=F(n,r),i=await V(this.ctx,e,r,t,!1);if(i!==void 0)return await U(this.ctx,e,i),await B(this.ctx,e),{...r,_version:i};await B(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 I(this.ctx,e,t)}getVersion(e,t,n){return L(this.ctx,e,t,n)}compareVersions(e,t,n,r){return R(this.ctx,e,t,n,r)}restoreVersion(e,t,n){return z(this.ctx,e,t,n)}pruneVersions(e,t){return B(this.ctx,e,t)}createVersionSnapshot(e,t,n,r,i){return H(this.ctx,e,t,n,r,i)}getTranslationStatus(e){return ie(this.ctx,e)}getTranslationStatusBatch(e){return Q(this.ctx,e)}getTranslationReport(e){return ae(this.ctx,e)}getLocaleStatus(e){return O(this.ctx,e)}setLocaleStatus(e,t,n){return k(this.ctx,e,t,n)}getLocaleStatusBatch(e){return A(this.ctx,e)}saveDraft(e,t,n,r){return G(this.ctx,e,t,n,r)}getDraft(e,t){return K(this.ctx,e,t)}deleteDraft(e,t){return q(this.ctx,e,t)}publishDraft(e,t,n){return J(this.ctx,e,t,n)}hasDraft(e,t){return Y(this.ctx,e,t)}countDrafts(e){return X(this.ctx,e)}hasDraftBatch(e,t){return Z(this.ctx,e,t)}compareDraftWithCurrent(e,t){return ne(this.ctx,e,t)}};function oe(e){return new $({admin:c(e),entity:e,logger:s().logger.child({content:e.name})})}export{$ as ContentClient,oe as createContentClient};
|
|
1
|
+
import{t as e}from"./versionable-Br_G5VGR.mjs";import{n as t,t as n}from"./errors-bZAGPYhp.mjs";import{i as r,n as i,r as a}from"./plugin-CG2tNeoG.mjs";import{t as o}from"./locale-utils-w0WO6J_P.mjs";import"server-only";import{getApp as s}from"@murumets-ee/core";import{createAdminClient as c}from"@murumets-ee/core/clients";import{schemaRegistry as l}from"@murumets-ee/db";import{eq as u,inArray as d}from"drizzle-orm";function f(e){return e.match(/\S+|\s+/g)??[]}function p(e,t){if(e===t)return e?[{type:`equal`,value:e}]:[];if(!e)return[{type:`add`,value:t}];if(!t)return[{type:`remove`,value:e}];let n=f(e),r=f(t),i=n.length,a=r.length,o=Array.from({length:i+1},()=>Array(a+1).fill(0));for(let e=1;e<=i;e++){let t=o[e],i=o[e-1];if(!(!t||!i))for(let o=1;o<=a;o++)n[e-1]===r[o-1]?t[o]=(i[o-1]??0)+1:t[o]=Math.max(i[o]??0,t[o-1]??0)}let s=[],c=i,l=a,u=[];for(;c>0||l>0;){let e=c>0?n[c-1]:void 0,t=l>0?r[l-1]:void 0;if(c>0&&l>0&&e!==void 0&&e===t)u.push({type:`equal`,value:e}),c--,l--;else if(l>0&&t!==void 0&&(c===0||(o[c]?.[l-1]??0)>=(o[c-1]?.[l]??0)))u.push({type:`add`,value:t}),l--;else if(e!==void 0)u.push({type:`remove`,value:e}),c--;else break}u.reverse();for(let e of u){let t=s[s.length-1];t&&t.type===e.type?t.value+=e.value:s.push({...e})}return s}function m(e){return e.replace(/<[^>]*>/g,` `).replace(/\s+/g,` `).trim()}function h(e){return e==null?``:e instanceof Date?e.toISOString():typeof e==`object`?JSON.stringify(e):String(e)}function g(e,t,n,r){if(JSON.stringify(t)===JSON.stringify(n))return null;switch(r?.type??v(t,n)){case`text`:case`slug`:return{field:e,type:`text`,from:t,to:n,segments:p(h(t),h(n))};case`richtext`:return{field:e,type:`richtext`,from:t,to:n,segments:p(m(h(t)),m(h(n)))};case`number`:return{field:e,type:`number`,from:t,to:n};case`boolean`:return{field:e,type:`boolean`,from:t,to:n};case`date`:return{field:e,type:`date`,from:t,to:n};case`select`:return{field:e,type:`select`,from:t,to:n};case`media`:return{field:e,type:`media`,from:t,to:n};case`json`:return{field:e,type:`json`,from:t,to:n};case`blockTree`:return{field:e,type:`blockTree`,from:t,to:n};default:return{field:e,type:`unknown`,from:t,to:n}}}function _(e,t,n){let r=new Set([...Object.keys(e),...Object.keys(t)]),i=[];for(let a of r){if(a===`id`||a===`createdAt`||a===`updatedAt`)continue;let r=g(a,e[a],t[a],n?.[a]);r&&i.push(r)}return i}function v(e,t){let n=e??t;if(typeof n==`number`)return`number`;if(typeof n==`boolean`)return`boolean`;if(n instanceof Date)return`date`;if(typeof n==`string`)return/^\d{4}-\d{2}-\d{2}/.test(n)?`date`:`text`;if(Array.isArray(n)){let e=n[0];return n.length>0&&typeof e==`object`&&e&&`type`in e&&`id`in e?`blockTree`:`json`}return typeof n==`object`?`json`:`unknown`}function y(e,t){if(typeof e==`string`||typeof t==`string`)return p(h(e),h(t))}function b(e){if(typeof e!=`object`||!e)return null;let t=e;if(typeof t.id!=`string`||typeof t.type!=`string`)return null;let n={...t.props??{}};return t.traits!==void 0&&(n.traits=t.traits),t.children!==void 0&&(n.children=t.children),{id:t.id,type:t.type,props:n}}function x(e,t){let n=new Set([...Object.keys(e),...Object.keys(t)]),r=[];for(let i of n){let n=e[i],a=t[i];JSON.stringify(n)!==JSON.stringify(a)&&r.push({field:i,from:n,to:a,segments:y(n,a)})}return r}function S(e){let t=new Map,n=0;for(let r of e){let e=b(r);e&&(t.set(e.id,{block:e,index:n}),n++)}return t}function C(e,t){let n=S(e),r=S(t),i=[],a=new Set;for(let[e,{block:t,index:o}]of r){a.add(e);let r=n.get(e);if(!r){i.push({blockId:e,blockType:t.type,status:`added`,toIndex:o});continue}let s=r.block,c=r.index,l=x(s.props,t.props),u=c!==o;l.length===0&&!u?i.push({blockId:e,blockType:t.type,status:`unchanged`,fromIndex:c,toIndex:o}):l.length>0?i.push({blockId:e,blockType:t.type,status:`changed`,fromIndex:c,toIndex:o,propDiffs:l}):i.push({blockId:e,blockType:t.type,status:`moved`,fromIndex:c,toIndex:o})}for(let[e,{block:t,index:r}]of n)a.has(e)||i.push({blockId:e,blockType:t.type,status:`removed`,fromIndex:r});return i}function w(e,t){return{entity:e.entity,admin:e.admin,db:e.admin.getDb(),versionableOptions:t,logger:e.logger}}function T(e){let{drafts:t}=r(e.entity.name);return t?t.makeClient(e.db):null}function E(e){let{versions:t}=r(e.entity.name);return t?t.makeClient(e.db):null}function D(e){let{localeStatus:t}=r(e.entity.name);return t?t.makeClient(e.db):null}async function O(e,t){let n=(await A(e,[t])).get(t);return n?Object.values(n):[]}async function k(e,n,r,s){if(o(r),r===i().defaultLocale){let t={status:s};s===`published`&&(t.publishedAt=new Date),await e.admin.update(n,t);return}let c=D(e);if(!c)throw new t(e.entity.name,`localeStatus`);await a(c,n,r,s)}async function A(e,t){let n=new Map;if(t.length===0)return n;let r=D(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 ee(e){return e===`_`||e===i().defaultLocale}function j(e){let{defaultLocale:t}=i();return ee(e)?{defaultLocale:t}:{locale:e,defaultLocale:t}}async function M(e,t,n,r){let a=D(e)!==null;return e.admin.updateForLocale(t,n,r,{defaultLocale:i().defaultLocale,...a&&{localeStatusWriter:(t,n,r)=>k(e,t,n,r)}})}const N=new Set([`id`,`createdAt`,`createdBy`,`updatedAt`,`updatedBy`]);function P(e,t){let n=e.allFields,r={};for(let e of Object.keys(t)){if(N.has(e)||e.startsWith(`_`)||!Object.hasOwn(n,e))continue;let i=n[e];i&&i.internal===!0||(r[e]=t[e])}return r}function te(e,t){return JSON.stringify(e)===JSON.stringify(t)}function F(e,t){let n={};for(let r of Object.keys(t))r===`id`||r.startsWith(`_`)||te(e[r],t[r])||(n[r]=t[r]);return n}async function I(e,t,n){n?.locale!==void 0&&o(n.locale);let r=E(e);return r?await r.findMany({where:{entityId:t,locale:n?.locale??`_`},orderBy:[{column:`version`,dir:`desc`}],...n?.limit===void 0?{}:{limit:n.limit},...n?.offset===void 0?{}:{offset:n.offset}}):[]}async function L(e,t,n,r=`_`){o(r);let i=E(e);return i?await i.findOne({entityId:t,version:n,locale:r})??null:null}async function R(e,t,n,r,i=`_`){o(i);let[a,s]=await Promise.all([L(e,t,n,i),L(e,t,r,i)]);if(!a||!s)return null;let c=e.entity.allFields,l=_(a.data,s.data,c);for(let e of l)e.type===`blockTree`&&(e.blockDiffs=C(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:l,fromVersion:a,toVersion:s}}async function z(e,n,r,i=`_`){if(o(i),!E(e))throw new t(e.entity.name,`versions`);let a=await L(e,n,r,i);if(!a)throw Error(`Version ${r} not found for entity ${n}`);let s=a.data;return M(e,n,P(e.entity,s),i)}async function B(e,t,n=`_`){if(o(n),!e.versionableOptions)return;let r=E(e);if(!r)return;let i=e.versionableOptions.maxPerDoc,a=await r.findMany({where:{entityId:t,locale:n},orderBy:[{column:`version`,dir:`desc`}],limit:i});if(a.length<i)return;let s=a.map(e=>e.id);await r.deleteMany({entityId:t,locale:n,id:{notIn:s}})}async function V(e,t,n,r,i,a=`_`,s){o(a);let c=E(e);if(!c)return;let[l]=await c.aggregate({select:{maxVersion:{fn:`max`,column:`version`}},where:{entityId:t,locale:a}}),u=Number(l?.maxVersion??0)+1,d=n.status??null;if(i){let e=await c.findOne({entityId:t,isAutosave:!0,version:u,locale:a});if(e)return await c.update({id:e.id},{data:n,delta:r,status:d,createdAt:new Date}),u}return await c.insert({entityId:t,version:u,locale:a,data:n,delta:r,status:d,createdBy:s?.id??null,createdByName:s?.name??null,isAutosave:i}),u}async function H(e,t,n,r,i,a){if(i!==void 0&&o(i),!e.versionableOptions)return;let s=i??`_`,c=await V(e,t,n,r?F(r,n):null,!1,s,a);c!==void 0&&s===`_`&&await U(e,t,c),await B(e,t,s)}async function U(e,t,n){let r=l.get(e.entity.name);r&&await e.db.update(r).set({_version:n}).where(u(r.id,t))}function W(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 G(e,n,r,i=`_`,a){o(i);let s=T(e);if(!s)throw new t(e.entity.name,`drafts`);let c=new Date,l=a?.id??``,u=a?.name??null;return W(await s.upsert({entityId:n,locale:i,data:r,createdBy:l,createdByName:u,createdAt:c,updatedAt:c},{target:[`entityId`,`locale`],set:{data:r,updatedAt:c}}))}async function K(e,t,n=`_`){o(n);let r=T(e);if(!r)return null;let i=await r.findOne({entityId:t,locale:n});return i?W(i):null}async function q(e,t,n=`_`){o(n);let r=T(e);return r?(await r.deleteMany({entityId:t,locale:n})).length>0:!1}async function J(e,r,i=`_`,a){if(o(i),!T(e))throw new t(e.entity.name,`drafts`);let s=await K(e,r,i);if(!s)throw new n(r,i);let c=null;e.versionableOptions&&(c=await e.admin.findById(r,j(i)));let l=s.data,u=l.status===void 0?{...l,status:`published`}:l,d=await M(e,r,P(e.entity,u),i);return await q(e,r,i),e.versionableOptions&&await H(e,r,d,c,i,a),d}async function Y(e,t,n=`_`){o(n);let r=T(e);return r?r.exists({entityId:t,locale:n}):!1}async function X(e,t=`_`){o(t);let n=T(e);return n?n.count({locale:t}):0}async function Z(e,t,n=`_`){o(n);let r=new Set;if(t.length===0)return r;let i=T(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 ne(e,t,n=`_`){o(n);let r=await K(e,t,n);if(!r)return null;let a=i().defaultLocale,s=n===`_`?{defaultLocale:a}:{locale:n,defaultLocale:a},c=await e.admin.findById(t,s);if(!c)return null;let l=e.entity.allFields,u=c,d=r.data,f={};for(let e of Object.keys(d))f[e]=u[e];let p=_(f,d,l);for(let e of p)e.type===`blockTree`&&(e.blockDiffs=C(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:p,current:c,draft:r}}function re(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 ie(e,t){return(await Q(e,[t])).get(t)??{}}async function Q(e,t){let n=new Map;if(t.length===0)return n;let r=i(),a=Object.entries(e.entity.allFields).filter(([e,t])=>t.translatable).map(([e])=>e);if(a.length===0)return n;let o=l.get(`${e.entity.name}_translations`);if(!o)return n;let s=await e.db.select().from(o).where(d(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,re(a,c.get(e),r.locales,r.defaultLocale));return n}async function ae(e,t){let n=i(),r=t?.locale??n.locales.find(e=>e.code!==n.defaultLocale)?.code;if(!r)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 Q(e,a.map(e=>e.id)),s=0,c=0,l=0;for(let e of a){let t=o.get(e.id)?.[r];!t||t.translated===0?l++:t.complete?s++:c++}return{total:a.length,complete:s,partial:c,missing:l}}var $=class{ctx;constructor(t){let n=e(t.entity.behaviors);this.ctx=w(t,n)}async create(e){let t=await this.ctx.admin.create(e);return this.ctx.versionableOptions&&await V(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=F(n,r),i=await V(this.ctx,e,r,t,!1);if(i!==void 0)return await U(this.ctx,e,i),await B(this.ctx,e),{...r,_version:i};await B(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 I(this.ctx,e,t)}getVersion(e,t,n){return L(this.ctx,e,t,n)}compareVersions(e,t,n,r){return R(this.ctx,e,t,n,r)}restoreVersion(e,t,n){return z(this.ctx,e,t,n)}pruneVersions(e,t){return B(this.ctx,e,t)}createVersionSnapshot(e,t,n,r,i){return H(this.ctx,e,t,n,r,i)}getTranslationStatus(e){return ie(this.ctx,e)}getTranslationStatusBatch(e){return Q(this.ctx,e)}getTranslationReport(e){return ae(this.ctx,e)}getLocaleStatus(e){return O(this.ctx,e)}setLocaleStatus(e,t,n){return k(this.ctx,e,t,n)}getLocaleStatusBatch(e){return A(this.ctx,e)}saveDraft(e,t,n,r){return G(this.ctx,e,t,n,r)}getDraft(e,t){return K(this.ctx,e,t)}deleteDraft(e,t){return q(this.ctx,e,t)}publishDraft(e,t,n){return J(this.ctx,e,t,n)}hasDraft(e,t){return Y(this.ctx,e,t)}countDrafts(e){return X(this.ctx,e)}hasDraftBatch(e,t){return Z(this.ctx,e,t)}compareDraftWithCurrent(e,t){return ne(this.ctx,e,t)}};function oe(e){return new $({admin:c(e),entity:e,logger:s().logger.child({content:e.name})})}export{$ as ContentClient,oe as createContentClient};
|
|
2
2
|
//# sourceMappingURL=client.mjs.map
|
package/dist/client.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"sources":["../src/diff/text-diff.ts","../src/diff/field-diff.ts","../src/diff/block-diff.ts","../src/internal/context.ts","../src/locale-status.ts","../src/internal/apply-locale-routed-update.ts","../src/internal/projection.ts","../src/versions.ts","../src/drafts.ts","../src/translation-status.ts","../src/client.ts"],"sourcesContent":["/**\n * Word-level text diff using a simplified Myers algorithm.\n *\n * Splits text into words (preserving whitespace), computes the longest common\n * subsequence, and returns an array of diff segments.\n */\n\nimport type { DiffSegment } from './types.js'\n\n/**\n * Split text into tokens — words and whitespace runs — so diffs are\n * human-readable (\"Changed *this* word\") rather than character-level.\n */\nfunction tokenize(text: string): string[] {\n return text.match(/\\S+|\\s+/g) ?? []\n}\n\n/**\n * Compute word-level diff between two strings.\n *\n * Uses a simple LCS (dynamic programming) approach which is fine for\n * the text sizes we deal with (entity field values, not source code).\n */\nexport function diffText(from: string, to: string): DiffSegment[] {\n if (from === to) {\n return from ? [{ type: 'equal', value: from }] : []\n }\n if (!from) {\n return [{ type: 'add', value: to }]\n }\n if (!to) {\n return [{ type: 'remove', value: from }]\n }\n\n const fromTokens = tokenize(from)\n const toTokens = tokenize(to)\n\n // Build LCS table\n const m = fromTokens.length\n const n = toTokens.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))\n\n for (let i = 1; i <= m; i++) {\n const dpI = dp[i]\n const dpIm1 = dp[i - 1]\n if (!dpI || !dpIm1) continue\n for (let j = 1; j <= n; j++) {\n if (fromTokens[i - 1] === toTokens[j - 1]) {\n dpI[j] = (dpIm1[j - 1] ?? 0) + 1\n } else {\n dpI[j] = Math.max(dpIm1[j] ?? 0, dpI[j - 1] ?? 0)\n }\n }\n }\n\n // Backtrack to build diff segments\n const segments: DiffSegment[] = []\n let i = m\n let j = n\n\n // Collect in reverse, then flip\n const reversed: DiffSegment[] = []\n\n while (i > 0 || j > 0) {\n const fromTok = i > 0 ? fromTokens[i - 1] : undefined\n const toTok = j > 0 ? toTokens[j - 1] : undefined\n if (i > 0 && j > 0 && fromTok !== undefined && fromTok === toTok) {\n reversed.push({ type: 'equal', value: fromTok })\n i--\n j--\n } else if (j > 0 && toTok !== undefined && (i === 0 || (dp[i]?.[j - 1] ?? 0) >= (dp[i - 1]?.[j] ?? 0))) {\n reversed.push({ type: 'add', value: toTok })\n j--\n } else if (fromTok !== undefined) {\n reversed.push({ type: 'remove', value: fromTok })\n i--\n } else {\n // Defensive: i and j both 0, but loop condition implied otherwise.\n break\n }\n }\n\n reversed.reverse()\n\n // Merge consecutive segments of the same type\n for (const seg of reversed) {\n const last = segments[segments.length - 1]\n if (last && last.type === seg.type) {\n last.value += seg.value\n } else {\n segments.push({ ...seg })\n }\n }\n\n return segments\n}\n","/**\n * Field-level diff — dispatches to the appropriate diff strategy per field type.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { diffText } from './text-diff.js'\nimport type { DiffSegment, FieldDiff } from './types.js'\n\n/** Strip HTML tags for diffing richtext content as plain text. */\nfunction stripHtml(html: string): string {\n return html\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** Stringify a value for display (dates, objects, etc.). */\nfunction stringify(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\n/**\n * Compute diff for a single field, using the field's type to determine\n * the best diff strategy.\n */\nexport function diffField(\n fieldName: string,\n from: unknown,\n to: unknown,\n fieldConfig?: FieldConfig,\n): FieldDiff | null {\n // No change\n if (JSON.stringify(from) === JSON.stringify(to)) return null\n\n const type = fieldConfig?.type ?? inferType(from, to)\n\n switch (type) {\n case 'text':\n case 'slug': {\n const segments = diffText(stringify(from), stringify(to))\n return { field: fieldName, type: 'text', from, to, segments }\n }\n\n case 'richtext': {\n // Diff on stripped HTML so users see word changes, not tag noise\n const segments = diffText(stripHtml(stringify(from)), stripHtml(stringify(to)))\n return { field: fieldName, type: 'richtext', from, to, segments }\n }\n\n case 'number':\n return { field: fieldName, type: 'number', from, to }\n\n case 'boolean':\n return { field: fieldName, type: 'boolean', from, to }\n\n case 'date':\n return { field: fieldName, type: 'date', from, to }\n\n case 'select':\n return { field: fieldName, type: 'select', from, to }\n\n case 'media':\n return { field: fieldName, type: 'media', from, to }\n\n case 'json':\n return { field: fieldName, type: 'json', from, to }\n\n case 'blockTree':\n // Block tree — block-diff.ts computes the structural diff; just mark the\n // field type so compareVersions/compareDraftWithCurrent populate\n // `blockDiffs`.\n return { field: fieldName, type: 'blockTree', from, to }\n\n default:\n return { field: fieldName, type: 'unknown', from, to }\n }\n}\n\n/**\n * Diff all fields between two version snapshots.\n * Pass fieldConfigs to get type-aware diffs; without them, types are inferred.\n */\nexport function diffFields(\n from: Record<string, unknown>,\n to: Record<string, unknown>,\n fieldConfigs?: Record<string, FieldConfig>,\n): FieldDiff[] {\n const allKeys = new Set([...Object.keys(from), ...Object.keys(to)])\n const diffs: FieldDiff[] = []\n\n for (const key of allKeys) {\n // Skip internal/auto-generated fields\n if (key === 'id' || key === 'createdAt' || key === 'updatedAt') continue\n\n const diff = diffField(key, from[key], to[key], fieldConfigs?.[key])\n if (diff) diffs.push(diff)\n }\n\n return diffs\n}\n\n/** Best-effort type inference when no FieldConfig is available. */\nfunction inferType(from: unknown, to: unknown): string {\n const val = from ?? to\n if (typeof val === 'number') return 'number'\n if (typeof val === 'boolean') return 'boolean'\n if (val instanceof Date) return 'date'\n if (typeof val === 'string') {\n if (/^\\d{4}-\\d{2}-\\d{2}/.test(val)) return 'date'\n return 'text'\n }\n if (Array.isArray(val)) {\n const first = val[0]\n if (val.length > 0 && typeof first === 'object' && first !== null) {\n // Block tree: instances discriminated by `type` + `id`.\n if ('type' in first && 'id' in first) return 'blockTree'\n }\n return 'json'\n }\n if (typeof val === 'object') return 'json'\n return 'unknown'\n}\n\n/** Diff two string values and return segments (used by block-diff for string props). */\nexport function diffStringValues(from: unknown, to: unknown): DiffSegment[] | undefined {\n if (typeof from === 'string' || typeof to === 'string') {\n return diffText(stringify(from), stringify(to))\n }\n return undefined\n}\n","/**\n * Structural diff for block-tree arrays (`field.blockTree()` values).\n *\n * Operates on new-format `BlockInstance`s (`{ id, type, props, traits,\n * children }`) — the only block shape the toolkit stores. Blocks are\n * identified by their `id`. The algorithm detects:\n * - Added blocks (new id in target)\n * - Removed blocks (missing id in target)\n * - Moved blocks (same id, different position)\n * - Changed blocks (same id, different prop/trait/child values)\n * - Unchanged blocks\n */\n\nimport { diffStringValues } from './field-diff.js'\nimport type { BlockDiffEntry, PropDiff } from './types.js'\n\n/** Comparable view of a block instance. */\ninterface NormalizedBlock {\n id: string\n type: string\n /** Comparable content map: `props` + folded-in `traits` / `children`. */\n props: Record<string, unknown>\n}\n\n/**\n * Normalize a `BlockInstance` to its comparable `{ id, type, props }` view,\n * or null when the value isn't a recognizable block. `traits` and `children`\n * fold into the comparable map so trait edits and nested-tree changes surface\n * as prop diffs (`diffProps` skips JSON-equal values, so empty/unchanged ones\n * add no noise).\n */\nfunction normalizeBlock(val: unknown): NormalizedBlock | null {\n if (typeof val !== 'object' || val === null) return null\n const rec = val as Record<string, unknown>\n\n if (typeof rec.id !== 'string' || typeof rec.type !== 'string') return null\n\n const props: Record<string, unknown> = { ...((rec.props as Record<string, unknown>) ?? {}) }\n if (rec.traits !== undefined) props.traits = rec.traits\n if (rec.children !== undefined) props.children = rec.children\n return { id: rec.id, type: rec.type, props }\n}\n\n/**\n * Diff two prop objects within a single block, returning per-prop diffs.\n */\nfunction diffProps(\n fromProps: Record<string, unknown>,\n toProps: Record<string, unknown>,\n): PropDiff[] {\n const allKeys = new Set([...Object.keys(fromProps), ...Object.keys(toProps)])\n const diffs: PropDiff[] = []\n\n for (const key of allKeys) {\n const fromVal = fromProps[key]\n const toVal = toProps[key]\n\n if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {\n diffs.push({\n field: key,\n from: fromVal,\n to: toVal,\n segments: diffStringValues(fromVal, toVal),\n })\n }\n }\n\n return diffs\n}\n\n/**\n * Index a block array by id, assigning each block its position AMONG\n * recognizable blocks (not its raw array index). Values that fail\n * normalization are skipped without consuming a position, so a stray\n * non-block entry can't skew move detection for the real blocks around it.\n */\nfunction indexBlocks(blocks: unknown[]): Map<string, { block: NormalizedBlock; index: number }> {\n const map = new Map<string, { block: NormalizedBlock; index: number }>()\n let position = 0\n for (const raw of blocks) {\n const block = normalizeBlock(raw)\n if (block) {\n map.set(block.id, { block, index: position })\n position++\n }\n }\n return map\n}\n\n/**\n * Compute structural diff between two block arrays.\n *\n * Each block is identified by its id. Position changes, additions, removals,\n * and prop-level changes are all detected.\n */\nexport function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[] {\n const fromMap = indexBlocks(fromBlocks)\n const toMap = indexBlocks(toBlocks)\n\n const entries: BlockDiffEntry[] = []\n const processedIds = new Set<string>()\n\n // Walk the \"to\" array to preserve target order\n for (const [id, { block: toBlock, index: toIndex }] of toMap) {\n processedIds.add(id)\n const fromEntry = fromMap.get(id)\n\n if (!fromEntry) {\n // Added\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'added',\n toIndex,\n })\n continue\n }\n\n const fromBlock = fromEntry.block\n const fromIndex = fromEntry.index\n const propDiffs = diffProps(fromBlock.props, toBlock.props)\n const moved = fromIndex !== toIndex\n\n if (propDiffs.length === 0 && !moved) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'unchanged',\n fromIndex,\n toIndex,\n })\n } else if (propDiffs.length > 0) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'changed',\n fromIndex,\n toIndex,\n propDiffs,\n })\n } else {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'moved',\n fromIndex,\n toIndex,\n })\n }\n }\n\n // Removed blocks (in \"from\" but not in \"to\")\n for (const [id, { block, index }] of fromMap) {\n if (!processedIds.has(id)) {\n entries.push({\n blockId: id,\n blockType: block.type,\n status: 'removed',\n fromIndex: index,\n })\n }\n }\n\n return entries\n}\n","/**\n * Shared per-instance state for ContentClient feature modules.\n *\n * The package's domain modules (`versions.ts`, `drafts.ts`,\n * `translation-status.ts`, `locale-status.ts`, `layout.ts`) are written\n * as free functions that take a `ContentContext` as their first\n * argument. ContentClient itself is a thin shell that builds the\n * context once and forwards every public method to the corresponding\n * free function.\n *\n * Why free functions instead of one class with everything inline:\n * - 1000+ LoC in one class is impossible to navigate and audit.\n * - Each module has clear inputs (the context + method args) and no\n * hidden dependencies on private state.\n * - Splits cleanly along the same boundaries as the per-entity\n * capability tables (drafts, versions, locale_status, ...) — every\n * module owns exactly one capability surface.\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { getVersionableOptions } from '../behaviors/versionable.js'\nimport { getContentTables } from '../schema/registry.js'\n\n/**\n * Per-instance content context. Built once in `createContentContext()`\n * and threaded through every domain module's free functions.\n */\nexport interface ContentContext<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n admin: AdminClient<AllFields>\n db: PostgresJsDatabase\n versionableOptions: ReturnType<typeof getVersionableOptions>\n logger?: Logger | undefined\n}\n\n/**\n * Build a content context from an AdminClient + entity. The DB handle\n * is read off the AdminClient (it owns the read-write connection).\n */\nexport function createContentContext<AllFields extends Record<string, FieldConfig>>(\n config: {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n },\n // Lazy import to avoid a static cycle: behaviors/versionable.ts is loaded\n // by client.ts which would otherwise close the loop. Phase-2 validated\n // the static import graph; this is a private helper bundled into the\n // same chunk so the dynamic-vs-static rule doesn't apply.\n versionableOptions: ReturnType<typeof getVersionableOptions>,\n): ContentContext<AllFields> {\n return {\n entity: config.entity,\n admin: config.admin,\n db: config.admin.getDb(),\n versionableOptions,\n logger: config.logger,\n }\n}\n\n// -------------------------------------------------------------------\n// Per-capability TableClient lookups. Each returns null if the entity\n// hasn't registered the corresponding table (i.e. doesn't have the\n// behavior). ContentClient public methods translate that null into\n// either a thrown EntityCapabilityError (writes) or a falsy return\n// (reads).\n// -------------------------------------------------------------------\n\nexport function getDraftsClient(ctx: ContentContext) {\n const { drafts } = getContentTables(ctx.entity.name)\n return drafts ? drafts.makeClient(ctx.db) : null\n}\n\nexport function getVersionsClient(ctx: ContentContext) {\n const { versions } = getContentTables(ctx.entity.name)\n return versions ? versions.makeClient(ctx.db) : null\n}\n\nexport function getLocaleStatusClient(ctx: ContentContext) {\n const { localeStatus } = getContentTables(ctx.entity.name)\n return localeStatus ? localeStatus.makeClient(ctx.db) : null\n}\n","/**\n * Per-locale publish status — independent draft/published state for\n * each locale of a publishable + translatable entity.\n *\n * Capability: requires `publishable() + at least one translatable\n * field` (the `_locale_status` table is registered only for that\n * combination). Reads silently return falsy on entities without it;\n * `setLocaleStatus` throws `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { EntityCapabilityError } from './errors.js'\nimport { type ContentContext, getLocaleStatusClient } from './internal/context.js'\nimport { upsertLocaleStatusRow } from './locale-status-slot.js'\nimport { validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { LocalePublishStatus } from './types.js'\n\n/**\n * Get per-locale publish status overrides for an entity.\n * Missing locale = inherits the base entity status (no row written).\n */\nexport async function getLocaleStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<LocalePublishStatus[]> {\n const map = await getLocaleStatusBatch(ctx, [entityId])\n const entry = map.get(entityId)\n if (!entry) return []\n return Object.values(entry)\n}\n\n/**\n * Set per-locale publish status. Upserts into the locale_status\n * table — except when `locale === defaultLocale`, which updates the\n * base entity status column directly (the per-locale table only\n * stores overrides for non-default locales).\n */\nexport async function setLocaleStatus<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n validateLocale(locale)\n const config = getContentConfig()\n\n // Default locale → update base entity status column.\n if (locale === config.defaultLocale) {\n const data: Record<string, unknown> = { status }\n if (status === 'published') data.publishedAt = new Date()\n await ctx.admin.update(entityId, data as InferUpdateInput<F>)\n return\n }\n\n const client = getLocaleStatusClient(ctx)\n if (!client) {\n // Per-locale status only exists for publishable + translatable\n // entities. Calling on a non-translatable publishable (where the\n // only valid locale is the default) is a programmer error.\n throw new EntityCapabilityError(ctx.entity.name, 'localeStatus')\n }\n\n await upsertLocaleStatusRow(client, entityId, locale, status)\n}\n\n/**\n * Get per-locale publish status for multiple entities in one query.\n */\nexport async function getLocaleStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, Record<string, LocalePublishStatus>>> {\n const result = new Map<string, Record<string, LocalePublishStatus>>()\n if (entityIds.length === 0) return result\n const client = getLocaleStatusClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n let entry = result.get(eid)\n if (!entry) {\n entry = {}\n result.set(eid, entry)\n }\n entry[locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n return result\n}\n","/**\n * Locale-routed entity write shared by `publishDraft` and `restoreVersion`.\n *\n * Since SD021, the routing itself lives in ONE place —\n * `AdminClient.updateForLocale` (`@murumets-ee/entity`): default locale →\n * base row; non-default → translatable fields to `saveTranslation`,\n * `status` to the per-locale status store, non-translatable remainder to\n * the base row. This module is the thin content-side call: it supplies the\n * plugin's `defaultLocale` and a per-call locale-status writer bound to\n * THIS context's tables, so routing is deterministic regardless of how\n * `ctx.admin` was constructed.\n *\n * Lives in its own internal module because `drafts.ts` imports\n * `versions.ts` (for `createVersionSnapshot`) — exporting this from\n * either of them would close an import cycle.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { setLocaleStatus } from '../locale-status.js'\nimport { DEFAULT_LOCALE_SENTINEL } from '../locale-utils.js'\nimport { getContentConfig } from '../plugin.js'\nimport { type ContentContext, getLocaleStatusClient } from './context.js'\n\n/** Is this snapshot-key locale the default locale (base-row) one? */\nexport function isDefaultLocale(locale: string): boolean {\n return locale === DEFAULT_LOCALE_SENTINEL || locale === getContentConfig().defaultLocale\n}\n\n/**\n * `findById` options that read the same locale view the write targets —\n * default locale reads the base row, non-default reads the\n * translation-merged row.\n */\nexport function localeFindOptions(\n locale: string,\n): { defaultLocale: string } | { locale: string; defaultLocale: string } {\n const { defaultLocale } = getContentConfig()\n return isDefaultLocale(locale) ? { defaultLocale } : { locale, defaultLocale }\n}\n\n/**\n * Apply projected entity-field data to the given locale via the SD021\n * choke point. `updateData` MUST already be projected through\n * `projectToEntityFields` — this function only delegates, it does not\n * whitelist.\n */\nexport async function applyLocaleRoutedUpdate<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n updateData: Record<string, unknown>,\n locale: string,\n): Promise<InferEntityDTO<F>> {\n // Bind the status writer to THIS context's locale-status table when the\n // entity has one; otherwise let updateForLocale fall back to the base\n // status column (non-translatable publishables have only the default\n // locale).\n const hasLocaleStatus = getLocaleStatusClient(ctx) !== null\n return ctx.admin.updateForLocale(entityId, updateData as InferUpdateInput<F>, locale, {\n defaultLocale: getContentConfig().defaultLocale,\n ...(hasLocaleStatus && {\n localeStatusWriter: (id: string, loc: string, status: 'draft' | 'published') =>\n setLocaleStatus(ctx, id, loc, status),\n }),\n })\n}\n","/**\n * Field projection + delta helpers shared between drafts and versions.\n *\n * `projectToEntityFields` is the security-critical whitelist filter\n * applied before replaying stored data through `admin.update()` —\n * see audit-security-content.md M#4 (drafts/restore must not pass\n * through forbidden fields like `status: 'published'` or\n * `_workflowStatus: 'approved'`).\n *\n * `valuesEqual` and `calculateDelta` are pure utilities used by both\n * the version-snapshot delta calculator and the block-diff prop\n * comparison — collapsing 3× repeated `JSON.stringify(a) !== JSON.stringify(b)`\n * sites identified in audit-quality-content.md DRY candidates.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\n/**\n * Identity / audit / timestamp fields that exist as real columns on every\n * entity (added by `defineEntity` / `auditable()` / `timestamped()`) and\n * MUST NOT be relayed through `admin.update()` from a draft or version\n * payload. They're either server-set (`createdAt`, `createdBy`,\n * `updatedAt`, `updatedBy`) or immutable identity (`id`).\n *\n * Without this explicit deny-list, `id` would survive `projectToEntityFields`\n * because `defineEntity` injects it into `entity.allFields` and it's not\n * marked `internal: true`. AdminClient.update's Zod schema strips it\n * downstream as a final defense, but security-critical filtering should\n * live in one place — not split across the projection AND the validator.\n */\nconst PROJECTION_DENYLIST = new Set(['id', 'createdAt', 'createdBy', 'updatedAt', 'updatedBy'])\n\n/**\n * Project a draft / version payload through the entity's declared\n * field map before forwarding to `admin.update()`. Used by\n * `publishDraft()` and `restoreVersion()` — both replay stored data\n * that originated from a write, but neither should be allowed to set\n * fields the entity didn't declare.\n *\n * Drops:\n * - the audit/identity column set above (`id`, `createdAt`,\n * `createdBy`, `updatedAt`, `updatedBy`)\n * - any key starting with `_` (behavior internals managed by hooks)\n * - any field declared `internal: true` (writable only via\n * AdminClient.updateInternal — never via public CRUD or replay)\n * - any key not in `entity.allFields` (whitelist for app-defined fields)\n */\nexport function projectToEntityFields(\n entity: Entity,\n data: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = entity.allFields as Record<string, FieldConfig>\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(data)) {\n if (PROJECTION_DENYLIST.has(key)) continue\n if (key.startsWith('_')) continue\n if (!Object.hasOwn(allowed, key)) continue\n const f = allowed[key]\n if (f && (f as { internal?: boolean }).internal === true) continue\n out[key] = data[key]\n }\n return out\n}\n\n/**\n * Structural equality via canonical JSON serialization. Coarse, with\n * three known quirks worth flagging at every call site:\n *\n * 1. **Key order matters in pathological cases.** Two objects with\n * identical keys+values but different insertion order serialize\n * differently. Safe for entity field values where keys come from\n * the same field map on both sides; not safe for arbitrary user\n * JSONB blobs.\n * 2. **Dates compare equal to their ISO string.** `new Date('2026-01-01')`\n * and `'2026-01-01T00:00:00.000Z'` both stringify to\n * `\"2026-01-01T00:00:00.000Z\"`. After a round-trip through JSONB\n * storage, a previously Date-typed field will come back as a\n * string and compare equal to its origin Date — exactly what we\n * want for delta calculation.\n * 3. **`undefined` is dropped.** `{ x: undefined }` and `{}` both\n * serialize to `{}`. If you need to distinguish \"field present\n * but null\" from \"field missing\", do not use this helper.\n */\nexport function valuesEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b)\n}\n\n/**\n * Field-level delta between previous and current entity snapshots.\n * Skips `id` and any underscore-prefixed behavior-internal field.\n * Only changed keys are returned. Used by version-snapshot persistence\n * to store a delta alongside the full snapshot for compact \"what\n * changed since last version\" reads.\n */\nexport function calculateDelta(\n previous: Record<string, unknown>,\n current: Record<string, unknown>,\n): Record<string, unknown> {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(current)) {\n if (key === 'id' || key.startsWith('_')) continue\n if (!valuesEqual(previous[key], current[key])) {\n delta[key] = current[key]\n }\n }\n return delta\n}\n","/**\n * Version-history operations on a content entity.\n *\n * All functions take a {@link ContentContext} as first argument and\n * are wired into ContentClient via thin delegation in `client.ts`.\n *\n * Capability: requires the entity to declare `versionable()`. Read\n * methods return falsy when the entity doesn't support versions\n * (defensive, supports mixed-entity loops in the dashboard); write\n * methods throw `EntityCapabilityError` mapped to HTTP 400 by admin-ui.\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO } from '@murumets-ee/entity/admin'\nimport { eq } from 'drizzle-orm'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { EntityCapabilityError } from './errors.js'\nimport { applyLocaleRoutedUpdate } from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getVersionsClient } from './internal/context.js'\nimport { calculateDelta, projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport type { VersionEntry } from './types.js'\n\n/**\n * List versions for an entity (newest first), filtered by locale.\n * Uses '_' as the locale value for default-locale snapshots.\n */\nexport async function getVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n options?: { limit?: number; offset?: number; locale?: string },\n): Promise<VersionEntry<InferEntityDTO<F>>[]> {\n if (options?.locale !== undefined) validateLocale(options.locale)\n const client = getVersionsClient(ctx)\n if (!client) return []\n\n const rows = await client.findMany({\n where: { entityId, locale: options?.locale ?? DEFAULT_LOCALE_SENTINEL },\n orderBy: [{ column: 'version', dir: 'desc' }],\n ...(options?.limit !== undefined ? { limit: options.limit } : {}),\n ...(options?.offset !== undefined ? { offset: options.offset } : {}),\n })\n return rows as VersionEntry<InferEntityDTO<F>>[]\n}\n\n/**\n * Get a specific version snapshot.\n */\nexport async function getVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<VersionEntry<InferEntityDTO<F>> | null> {\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, version, locale })\n return (row ?? null) as VersionEntry<InferEntityDTO<F>> | null\n}\n\n/**\n * Compare two versions and return field-level diff.\n *\n * Returns structured diffs with word-level segments for text fields\n * and structural block diffs for blocks fields.\n */\nexport async function compareVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n v1: number,\n v2: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n fromVersion: VersionEntry<InferEntityDTO<F>>\n toVersion: VersionEntry<InferEntityDTO<F>>\n} | null> {\n validateLocale(locale)\n const [version1, version2] = await Promise.all([\n getVersion(ctx, entityId, v1, locale),\n getVersion(ctx, entityId, v2, locale),\n ])\n if (!version1 || !version2) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const diffs = diffFields(version1.data, version2.data, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n return { diffs, fromVersion: version1, toVersion: version2 }\n}\n\n/**\n * Restore an entity to a previous version. Replays the snapshot data\n * through the locale-routed writer, projected through `entity.allFields`\n * so forbidden fields (workflow internals, behavior-managed underscore\n * fields, fields the entity declared `internal: true`) cannot leak.\n *\n * Snapshots are locale-keyed; the restore reads the snapshot for the\n * given locale and writes it back to the same locale's home —\n * translations for non-default locales, the base row for the default.\n */\nexport async function restoreVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getVersionsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'versions')\n }\n const versionData = await getVersion(ctx, entityId, version, locale)\n if (!versionData) {\n throw new Error(`Version ${version} not found for entity ${entityId}`)\n }\n const data = versionData.data as Record<string, unknown>\n const updateData = projectToEntityFields(ctx.entity, data)\n return applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n}\n\n/**\n * Enforce maxPerDoc retention by deleting oldest versions for a locale.\n */\nexport async function pruneVersions(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<void> {\n validateLocale(locale)\n if (!ctx.versionableOptions) return\n const client = getVersionsClient(ctx)\n if (!client) return\n\n const maxPerDoc = ctx.versionableOptions.maxPerDoc\n\n // Get IDs of versions to keep (newest N for this locale). The findMany\n // is capped at `limit: maxPerDoc`, so `keepVersions.length` is\n // `min(totalRows, maxPerDoc)` — never larger than the limit.\n const keepVersions = await client.findMany({\n where: { entityId, locale },\n orderBy: [{ column: 'version', dir: 'desc' }],\n limit: maxPerDoc,\n })\n\n // Strictly under the limit ⇒ totalRows < maxPerDoc ⇒ nothing to prune.\n // At the limit (length == maxPerDoc) we cannot distinguish \"exactly\n // maxPerDoc\" from \"more than maxPerDoc\" without another count query,\n // so we always run the DELETE — Postgres correctly removes 0-or-more\n // older rows. Better one always-cheap DELETE per prune than a guard\n // round trip on every call.\n if (keepVersions.length < maxPerDoc) return\n\n const keepIds = keepVersions.map((v) => v.id as string)\n\n // Delete everything for this entity+locale NOT in the keep list.\n await client.deleteMany({\n entityId,\n locale,\n id: { notIn: keepIds },\n })\n}\n\n/**\n * Persist a new version snapshot. Internal helper used by\n * `createVersionSnapshot` and ContentClient.update / .create.\n *\n * Attribution: ONLY the explicit `user` parameter is trusted. The\n * fallback chain that previously read `data.updatedBy` /\n * `data.createdBy` was an audit-log integrity gap (see audit\n * MEDIUM #1) — version history must never absorb a forged actor from\n * the request payload.\n */\nexport async function saveVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n data: Record<string, unknown>,\n delta: Record<string, unknown> | null,\n isAutosave: boolean,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<number | undefined> {\n // All current callers pass a pre-validated locale (or the sentinel).\n // Defensive check at the boundary so future callers — or anyone who\n // exposes this helper publicly — still get the validation.\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return\n\n // Get the next version number for this locale (max existing + 1).\n const [maxRow] = await client.aggregate<{ maxVersion: number | null }>({\n select: { maxVersion: { fn: 'max', column: 'version' } },\n where: { entityId, locale },\n })\n const version = Number(maxRow?.maxVersion ?? 0) + 1\n const status = (data.status as string) ?? null\n\n // Autosave: update the existing autosave row at the same version\n // number, preserving \"autosaves land on the latest slot\" semantics.\n if (isAutosave) {\n const existing = await client.findOne({ entityId, isAutosave: true, version, locale })\n if (existing) {\n await client.update(\n { id: existing.id as string },\n { data, delta, status, createdAt: new Date() },\n )\n return version\n }\n }\n\n await client.insert({\n entityId,\n version,\n locale,\n data,\n delta,\n status,\n createdBy: user?.id ?? null,\n createdByName: user?.name ?? null,\n isAutosave,\n })\n return version\n}\n\n/**\n * Create a version snapshot for an entity after an external update.\n * Use this when the update was performed by AdminClient directly\n * (e.g. the admin API handler) rather than through ContentClient.update().\n *\n * @param locale - The locale being edited. Use undefined or '_' for the default locale.\n * @param user - The user who made the change (id + name for display).\n */\nexport async function createVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n): Promise<void> {\n if (locale !== undefined) validateLocale(locale)\n if (!ctx.versionableOptions) return\n const snapshotLocale = locale ?? DEFAULT_LOCALE_SENTINEL\n const delta = previousData ? calculateDelta(previousData, currentData) : null\n const version = await saveVersionSnapshot(\n ctx,\n entityId,\n currentData,\n delta,\n false,\n snapshotLocale,\n user,\n )\n\n // Update _version on the entity to match (default locale only).\n if (version !== undefined && snapshotLocale === DEFAULT_LOCALE_SENTINEL) {\n await writeVersionColumn(ctx, entityId, version)\n }\n\n await pruneVersions(ctx, entityId, snapshotLocale)\n}\n\n/**\n * Write the `_version` column on the entity row.\n *\n * KNOWN EXCEPTION: this bypasses AdminClient and goes straight to the\n * entity pgTable via `schemaRegistry` + direct Drizzle. Using\n * `admin.update()` would retrigger the `versionable.beforeUpdate` hook\n * and loop indefinitely. The only ContentClient path that still writes\n * to an entity table directly.\n */\nexport async function writeVersionColumn(\n ctx: ContentContext,\n entityId: string,\n version: number,\n): Promise<void> {\n const table = schemaRegistry.get(ctx.entity.name)\n if (!table) return\n await ctx.db.update(table).set({ _version: version }).where(eq(table.id, entityId))\n}\n","/**\n * Draft-overlay operations.\n *\n * Capability: requires the entity to declare `publishable()`. Reads\n * (`getDraft`, `hasDraft`, `countDrafts`, `hasDraftBatch`) silently\n * return falsy when the entity doesn't support drafts; writes\n * (`saveDraft`, `publishDraft`) throw `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { DraftNotFoundError, EntityCapabilityError } from './errors.js'\nimport {\n applyLocaleRoutedUpdate,\n localeFindOptions,\n} from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getDraftsClient } from './internal/context.js'\nimport { projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { DraftsTable } from './schema/drafts.js'\nimport type { DraftEntry } from './types.js'\nimport { createVersionSnapshot } from './versions.js'\n\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\n\nfunction toDraftEntry(row: DraftRow): DraftEntry {\n return {\n id: row.id,\n entityId: row.entityId,\n locale: row.locale,\n data: row.data,\n createdBy: row.createdBy,\n createdByName: row.createdByName ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }\n}\n\n/**\n * Save (upsert) a draft for an entity+locale. One draft per\n * entity+locale — overwrites any existing draft.\n */\nexport async function saveDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n data: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>>> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const now = new Date()\n const createdBy = user?.id ?? ''\n const createdByName = user?.name ?? null\n\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: data as Record<string, unknown>,\n createdBy,\n createdByName,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only update the mutable fields — preserve createdBy/createdAt.\n set: { data: data as Record<string, unknown>, updatedAt: now },\n },\n )\n\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * Get the draft for an entity+locale, or null if none exists (or the\n * entity doesn't support drafts).\n */\nexport async function getDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, locale })\n return row ? (toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>) : null\n}\n\n/**\n * Delete (discard) a draft for an entity+locale. Returns whether\n * anything was deleted; safely no-ops on entities without drafts.\n */\nexport async function deleteDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n const deleted = await client.deleteMany({ entityId, locale })\n return deleted.length > 0\n}\n\n/**\n * Publish a draft: project the draft data through the entity field\n * whitelist, apply it to the locale the draft belongs to, then delete\n * the draft and (if versionable) record a version snapshot.\n *\n * Locale routing mirrors the admin PATCH handler (and the taxonomy fix\n * in 10a7cc2b): a default-locale draft writes the base row; a\n * non-default-locale draft routes translatable fields to\n * `saveTranslation` (→ `{entity}_translations`), `status` to the\n * per-locale status table, and only non-translatable fields (which have\n * no per-locale home) to the base row. Without this split, publishing\n * an `et` draft overwrote the default-locale base row wholesale.\n */\nexport async function publishDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getDraftsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n }\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) {\n throw new DraftNotFoundError(entityId, locale)\n }\n\n // Get previous state for the version delta — locale-merged, so the\n // delta compares against what this locale's readers actually saw.\n let previousState: InferEntityDTO<F> | null = null\n if (ctx.versionableOptions) {\n previousState = await ctx.admin.findById(entityId, localeFindOptions(locale))\n }\n\n // Apply draft data, projecting through the entity's field whitelist:\n // anything not declared in `entity.allFields` (or marked `internal:\n // true`) is silently dropped. NB `status` IS a declared publishable()\n // field and survives the projection — the publish-permission gate lives\n // in the admin route (`requirePermission(…, 'publish')` before\n // publishDraft is called), not here.\n const draftData = draft.data as Record<string, unknown>\n // \"Publish\" means MAKE LIVE. Block-editor drafts carry only content\n // (layout/theme/meta) — without this merge nothing ever flipped `status`,\n // so publishing promoted the content while the document stayed a draft\n // (badge \"Draft\", second click 409 — PR 23 user-smoke find). A draft that\n // explicitly carries `status` (form-save drafts can) wins; the\n // publishable() behavior auto-sets `publishedAt` on the transition.\n // `!== undefined` rather than `in` — a programmatic saveDraft can store\n // `status: undefined` (JSON can't), and that must not skip the merge.\n const dataWithStatus =\n draftData.status !== undefined ? draftData : { ...draftData, status: 'published' }\n const updateData = projectToEntityFields(ctx.entity, dataWithStatus)\n const result = await applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n\n await deleteDraft(ctx, entityId, locale)\n\n if (ctx.versionableOptions) {\n await createVersionSnapshot(ctx, entityId, result, previousState, locale, user)\n }\n\n return result\n}\n\n/**\n * Quick existence check for list badges — does a draft exist?\n */\nexport async function hasDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n return client.exists({ entityId, locale })\n}\n\n/**\n * Count drafts for this entity type, optionally filtered by locale.\n * Single indexed COUNT — used by dashboard widgets.\n */\nexport async function countDrafts(\n ctx: ContentContext,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<number> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return 0\n return client.count({ locale })\n}\n\n/**\n * Check draft existence for multiple entities in one query. Returns\n * a set of entity IDs that have a draft for `locale`.\n */\nexport async function hasDraftBatch(\n ctx: ContentContext,\n entityIds: string[],\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<Set<string>> {\n validateLocale(locale)\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n const client = getDraftsClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n return result\n}\n\n/**\n * Compare the current draft with the live entity. Returns field-level\n * diffs between the published data and the draft (only for fields\n * present in the draft — missing fields are NOT \"changed to empty\").\n */\nexport async function compareDraftWithCurrent<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n current: InferEntityDTO<F>\n draft: DraftEntry<InferUpdateInput<F>>\n} | null> {\n validateLocale(locale)\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) return null\n\n // Fetch the live entity with the same locale context as the draft.\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions =\n locale === DEFAULT_LOCALE_SENTINEL ? { defaultLocale } : { locale, defaultLocale }\n const current = await ctx.admin.findById(entityId, findOptions)\n if (!current) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const currentRecord = current as Record<string, unknown>\n const draftRecord = draft.data as Record<string, unknown>\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draftRecord)) {\n currentSubset[key] = currentRecord[key]\n }\n const diffs = diffFields(currentSubset, draftRecord, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Translation status reporting — per-locale completion of translatable\n * fields for one entity, batches of entities, or the whole entity type.\n *\n * Capability: requires the entity to have at least one translatable\n * field. All methods return falsy / empty when the entity has no\n * translatable fields (read-only and defensive).\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { inArray } from 'drizzle-orm'\nimport type { ContentContext } from './internal/context.js'\nimport { getContentConfig } from './plugin.js'\nimport type { TranslationReport, TranslationStatus } from './types.js'\n\n/**\n * For one entity, compute per-locale completion status across every\n * translatable field. Default-locale rows are skipped.\n */\nfunction computeStatusForEntity(\n translatableFields: string[],\n translatedByLocale: Map<string, Record<string, unknown>> | undefined,\n locales: { code: string }[],\n defaultLocale: string,\n): TranslationStatus {\n const status: TranslationStatus = {}\n for (const locale of locales) {\n if (locale.code === defaultLocale) continue\n const translated = translatedByLocale?.get(locale.code) ?? {}\n const missing: string[] = []\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatableFields.length - missing.length,\n missing,\n }\n }\n return status\n}\n\n/**\n * Get translation status for an entity — per-locale completion report.\n */\nexport async function getTranslationStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<TranslationStatus> {\n // Single-entity is the batch path with one ID — keeps the two\n // codepaths from drifting.\n const map = await getTranslationStatusBatch(ctx, [entityId])\n return map.get(entityId) ?? {}\n}\n\n/**\n * Get translation status for multiple entities in a single query.\n * O(1) DB roundtrips regardless of `entityIds.length`.\n */\nexport async function getTranslationStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const config = getContentConfig()\n\n const translatableFields = Object.entries(ctx.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK\n // refs); accessed via schemaRegistry + direct Drizzle here.\n const translationTable = schemaRegistry.get(`${ctx.entity.name}_translations`)\n if (!translationTable) return result\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(inArray(translationTable.entityId, entityIds))\n\n // Group: entityId → locale → { fieldName: value }\n const translationsByEntity = new Map<string, Map<string, Record<string, unknown>>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const eid = row.entityId as string\n const locale = row.locale as string\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n let entry = translationsByEntity.get(eid)\n if (!entry) {\n entry = new Map()\n translationsByEntity.set(eid, entry)\n }\n entry.set(locale, fieldValues)\n }\n\n for (const entityId of entityIds) {\n result.set(\n entityId,\n computeStatusForEntity(\n translatableFields,\n translationsByEntity.get(entityId),\n config.locales,\n config.defaultLocale,\n ),\n )\n }\n\n return result\n}\n\n/**\n * Roll up translation status across every entity of this type.\n *\n * Defaults to the first non-default configured locale; pass\n * `options.locale` to target a specific one. Returns counts of\n * complete / partial / missing entities for that locale.\n */\nexport async function getTranslationReport(\n ctx: ContentContext,\n options?: { locale?: string },\n): Promise<TranslationReport> {\n const config = getContentConfig()\n const targetLocale =\n options?.locale ?? config.locales.find((l) => l.code !== config.defaultLocale)?.code\n if (!targetLocale) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n const entities = await ctx.admin.findMany()\n if (entities.length === 0) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n // Single batched query covers every entity at once.\n const statusMap = await getTranslationStatusBatch(\n ctx,\n entities.map((e) => e.id),\n )\n\n let complete = 0\n let partial = 0\n let missing = 0\n for (const entity of entities) {\n const status = statusMap.get(entity.id)\n const localeStatus = status?.[targetLocale]\n if (!localeStatus || localeStatus.translated === 0) missing++\n else if (localeStatus.complete) complete++\n else partial++\n }\n\n return { total: entities.length, complete, partial, missing }\n}\n","/**\n * ContentClient — thin shell over per-feature modules.\n *\n * Wraps AdminClient with version history (`./versions.ts`), draft\n * overlays (`./drafts.ts`), translation status reporting\n * (`./translation-status.ts`), per-locale publish status\n * (`./locale-status.ts`), and block-layout operations (`./layout.ts`).\n *\n * The class is generic over the entity's `AllFields` map: when\n * constructed with a typed entity, every method's input/output is\n * inferred end-to-end. When constructed with a runtime `Entity`\n * (admin-ui's heterogeneous entity loops), the generic defaults to\n * `Record<string, FieldConfig>` and methods accept/return the loose\n * shape — same behavior as the pre-generic version.\n *\n * Usage:\n * import { createContentClient } from '@murumets-ee/content/client'\n * const pages = createContentClient(Page)\n * await pages.create({ title: 'Hello', layout: [...] })\n */\n\nimport 'server-only'\n\nimport { getApp, type Logger } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type {\n AdminClient,\n FindManyOptions,\n InferCreateInput,\n InferEntityDTO,\n InferUpdateInput,\n} from '@murumets-ee/entity/admin'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport {\n compareDraftWithCurrent,\n countDrafts,\n deleteDraft,\n getDraft,\n hasDraft,\n hasDraftBatch,\n publishDraft,\n saveDraft,\n} from './drafts.js'\nimport { type ContentContext, createContentContext } from './internal/context.js'\nimport { calculateDelta } from './internal/projection.js'\nimport { getLocaleStatus, getLocaleStatusBatch, setLocaleStatus } from './locale-status.js'\nimport {\n getTranslationReport,\n getTranslationStatus,\n getTranslationStatusBatch,\n} from './translation-status.js'\nimport {\n compareVersions,\n createVersionSnapshot,\n getVersion,\n getVersions,\n pruneVersions,\n restoreVersion,\n saveVersionSnapshot,\n writeVersionColumn,\n} from './versions.js'\n\nexport interface ContentClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n}\n\nexport class ContentClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private ctx: ContentContext<AllFields>\n\n constructor(config: ContentClientConfig<AllFields>) {\n const versionableOptions = getVersionableOptions(config.entity.behaviors)\n this.ctx = createContentContext(config, versionableOptions)\n }\n\n // -----------------------------------------------------------------\n // CRUD (delegated to AdminClient + version snapshots)\n // -----------------------------------------------------------------\n\n async create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n const result = await this.ctx.admin.create(data)\n if (this.ctx.versionableOptions) {\n await saveVersionSnapshot(this.ctx, result.id, result, null, false)\n }\n return result\n }\n\n findById(id: string, options?: { locale?: string }): Promise<InferEntityDTO<AllFields> | null> {\n return this.ctx.admin.findById(id, options)\n }\n\n findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n return this.ctx.admin.findMany(options)\n }\n\n /**\n * Base-row update + version snapshot. Subject to AdminClient's\n * cross-locale write guard (SD021): under a non-default request-context\n * locale, translatable fields throw `CrossLocaleWriteError` — that's\n * enforcement, not a gap. Locale-belonging writes go through the draft\n * flow (`saveDraft` → `publishDraft`) or `admin.updateForLocale` directly.\n */\n async update(id: string, data: InferUpdateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n let previousState: InferEntityDTO<AllFields> | null = null\n if (this.ctx.versionableOptions) {\n previousState = await this.ctx.admin.findById(id)\n }\n\n const result = await this.ctx.admin.update(id, data)\n\n if (this.ctx.versionableOptions && previousState) {\n const delta = calculateDelta(previousState, result)\n const version = await saveVersionSnapshot(this.ctx, id, result, delta, false)\n if (version !== undefined) {\n await writeVersionColumn(this.ctx, id, version)\n await pruneVersions(this.ctx, id)\n return { ...result, _version: version } as InferEntityDTO<AllFields>\n }\n await pruneVersions(this.ctx, id)\n }\n\n return result\n }\n\n delete(id: string): Promise<unknown> {\n return this.ctx.admin.delete(id)\n }\n\n // -----------------------------------------------------------------\n // Translation delegation\n // -----------------------------------------------------------------\n\n saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n return this.ctx.admin.saveTranslation(entityId, locale, translations)\n }\n\n deleteTranslation(entityId: string, locale?: string) {\n return this.ctx.admin.deleteTranslation(entityId, locale)\n }\n\n // -----------------------------------------------------------------\n // Version history\n // -----------------------------------------------------------------\n\n getVersions(entityId: string, options?: { limit?: number; offset?: number; locale?: string }) {\n return getVersions(this.ctx, entityId, options)\n }\n\n getVersion(entityId: string, version: number, locale?: string) {\n return getVersion(this.ctx, entityId, version, locale)\n }\n\n compareVersions(entityId: string, v1: number, v2: number, locale?: string) {\n return compareVersions(this.ctx, entityId, v1, v2, locale)\n }\n\n restoreVersion(entityId: string, version: number, locale?: string) {\n return restoreVersion(this.ctx, entityId, version, locale)\n }\n\n pruneVersions(entityId: string, locale?: string) {\n return pruneVersions(this.ctx, entityId, locale)\n }\n\n createVersionSnapshot(\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return createVersionSnapshot(this.ctx, entityId, currentData, previousData, locale, user)\n }\n\n // -----------------------------------------------------------------\n // Translation status\n // -----------------------------------------------------------------\n\n getTranslationStatus(entityId: string) {\n return getTranslationStatus(this.ctx, entityId)\n }\n\n getTranslationStatusBatch(entityIds: string[]) {\n return getTranslationStatusBatch(this.ctx, entityIds)\n }\n\n getTranslationReport(options?: { locale?: string }) {\n return getTranslationReport(this.ctx, options)\n }\n\n // -----------------------------------------------------------------\n // Per-locale publish status\n // -----------------------------------------------------------------\n\n getLocaleStatus(entityId: string) {\n return getLocaleStatus(this.ctx, entityId)\n }\n\n setLocaleStatus(entityId: string, locale: string, status: 'draft' | 'published') {\n return setLocaleStatus(this.ctx, entityId, locale, status)\n }\n\n getLocaleStatusBatch(entityIds: string[]) {\n return getLocaleStatusBatch(this.ctx, entityIds)\n }\n\n // -----------------------------------------------------------------\n // Drafts\n // -----------------------------------------------------------------\n\n saveDraft(\n entityId: string,\n data: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return saveDraft(this.ctx, entityId, data, locale, user)\n }\n\n getDraft(entityId: string, locale?: string) {\n return getDraft(this.ctx, entityId, locale)\n }\n\n deleteDraft(entityId: string, locale?: string) {\n return deleteDraft(this.ctx, entityId, locale)\n }\n\n publishDraft(entityId: string, locale?: string, user?: { id: string; name?: string }) {\n return publishDraft(this.ctx, entityId, locale, user)\n }\n\n hasDraft(entityId: string, locale?: string) {\n return hasDraft(this.ctx, entityId, locale)\n }\n\n countDrafts(locale?: string) {\n return countDrafts(this.ctx, locale)\n }\n\n hasDraftBatch(entityIds: string[], locale?: string) {\n return hasDraftBatch(this.ctx, entityIds, locale)\n }\n\n compareDraftWithCurrent(entityId: string, locale?: string) {\n return compareDraftWithCurrent(this.ctx, entityId, locale)\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity. Must be called\n * after `createApp()`. Generic flows from the entity definition:\n * `createContentClient(Article)` returns a `ContentClient<typeof\n * Article['allFields']>` typed end-to-end.\n */\nexport function createContentClient<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n): ContentClient<AllFields> {\n const admin = createAdminClient(entity)\n return new ContentClient<AllFields>({\n admin,\n entity,\n logger: getApp().logger.child({ content: entity.name }),\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../src/diff/text-diff.ts","../src/diff/field-diff.ts","../src/diff/block-diff.ts","../src/internal/context.ts","../src/locale-status.ts","../src/internal/apply-locale-routed-update.ts","../src/internal/projection.ts","../src/versions.ts","../src/drafts.ts","../src/translation-status.ts","../src/client.ts"],"sourcesContent":["/**\n * Word-level text diff using a simplified Myers algorithm.\n *\n * Splits text into words (preserving whitespace), computes the longest common\n * subsequence, and returns an array of diff segments.\n */\n\nimport type { DiffSegment } from './types.js'\n\n/**\n * Split text into tokens — words and whitespace runs — so diffs are\n * human-readable (\"Changed *this* word\") rather than character-level.\n */\nfunction tokenize(text: string): string[] {\n return text.match(/\\S+|\\s+/g) ?? []\n}\n\n/**\n * Compute word-level diff between two strings.\n *\n * Uses a simple LCS (dynamic programming) approach which is fine for\n * the text sizes we deal with (entity field values, not source code).\n */\nexport function diffText(from: string, to: string): DiffSegment[] {\n if (from === to) {\n return from ? [{ type: 'equal', value: from }] : []\n }\n if (!from) {\n return [{ type: 'add', value: to }]\n }\n if (!to) {\n return [{ type: 'remove', value: from }]\n }\n\n const fromTokens = tokenize(from)\n const toTokens = tokenize(to)\n\n // Build LCS table\n const m = fromTokens.length\n const n = toTokens.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))\n\n for (let i = 1; i <= m; i++) {\n const dpI = dp[i]\n const dpIm1 = dp[i - 1]\n if (!dpI || !dpIm1) continue\n for (let j = 1; j <= n; j++) {\n if (fromTokens[i - 1] === toTokens[j - 1]) {\n dpI[j] = (dpIm1[j - 1] ?? 0) + 1\n } else {\n dpI[j] = Math.max(dpIm1[j] ?? 0, dpI[j - 1] ?? 0)\n }\n }\n }\n\n // Backtrack to build diff segments\n const segments: DiffSegment[] = []\n let i = m\n let j = n\n\n // Collect in reverse, then flip\n const reversed: DiffSegment[] = []\n\n while (i > 0 || j > 0) {\n const fromTok = i > 0 ? fromTokens[i - 1] : undefined\n const toTok = j > 0 ? toTokens[j - 1] : undefined\n if (i > 0 && j > 0 && fromTok !== undefined && fromTok === toTok) {\n reversed.push({ type: 'equal', value: fromTok })\n i--\n j--\n } else if (j > 0 && toTok !== undefined && (i === 0 || (dp[i]?.[j - 1] ?? 0) >= (dp[i - 1]?.[j] ?? 0))) {\n reversed.push({ type: 'add', value: toTok })\n j--\n } else if (fromTok !== undefined) {\n reversed.push({ type: 'remove', value: fromTok })\n i--\n } else {\n // Defensive: i and j both 0, but loop condition implied otherwise.\n break\n }\n }\n\n reversed.reverse()\n\n // Merge consecutive segments of the same type\n for (const seg of reversed) {\n const last = segments[segments.length - 1]\n if (last && last.type === seg.type) {\n last.value += seg.value\n } else {\n segments.push({ ...seg })\n }\n }\n\n return segments\n}\n","/**\n * Field-level diff — dispatches to the appropriate diff strategy per field type.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { diffText } from './text-diff.js'\nimport type { DiffSegment, FieldDiff } from './types.js'\n\n/** Strip HTML tags for diffing richtext content as plain text. */\nfunction stripHtml(html: string): string {\n return html\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** Stringify a value for display (dates, objects, etc.). */\nfunction stringify(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\n/**\n * Compute diff for a single field, using the field's type to determine\n * the best diff strategy.\n */\nexport function diffField(\n fieldName: string,\n from: unknown,\n to: unknown,\n fieldConfig?: FieldConfig,\n): FieldDiff | null {\n // No change\n if (JSON.stringify(from) === JSON.stringify(to)) return null\n\n const type = fieldConfig?.type ?? inferType(from, to)\n\n switch (type) {\n case 'text':\n case 'slug': {\n const segments = diffText(stringify(from), stringify(to))\n return { field: fieldName, type: 'text', from, to, segments }\n }\n\n case 'richtext': {\n // Diff on stripped HTML so users see word changes, not tag noise\n const segments = diffText(stripHtml(stringify(from)), stripHtml(stringify(to)))\n return { field: fieldName, type: 'richtext', from, to, segments }\n }\n\n case 'number':\n return { field: fieldName, type: 'number', from, to }\n\n case 'boolean':\n return { field: fieldName, type: 'boolean', from, to }\n\n case 'date':\n return { field: fieldName, type: 'date', from, to }\n\n case 'select':\n return { field: fieldName, type: 'select', from, to }\n\n case 'media':\n return { field: fieldName, type: 'media', from, to }\n\n case 'json':\n return { field: fieldName, type: 'json', from, to }\n\n case 'blockTree':\n // Block tree — block-diff.ts computes the structural diff; just mark the\n // field type so compareVersions/compareDraftWithCurrent populate\n // `blockDiffs`.\n return { field: fieldName, type: 'blockTree', from, to }\n\n default:\n return { field: fieldName, type: 'unknown', from, to }\n }\n}\n\n/**\n * Diff all fields between two version snapshots.\n * Pass fieldConfigs to get type-aware diffs; without them, types are inferred.\n */\nexport function diffFields(\n from: Record<string, unknown>,\n to: Record<string, unknown>,\n fieldConfigs?: Record<string, FieldConfig>,\n): FieldDiff[] {\n const allKeys = new Set([...Object.keys(from), ...Object.keys(to)])\n const diffs: FieldDiff[] = []\n\n for (const key of allKeys) {\n // Skip internal/auto-generated fields\n if (key === 'id' || key === 'createdAt' || key === 'updatedAt') continue\n\n const diff = diffField(key, from[key], to[key], fieldConfigs?.[key])\n if (diff) diffs.push(diff)\n }\n\n return diffs\n}\n\n/** Best-effort type inference when no FieldConfig is available. */\nfunction inferType(from: unknown, to: unknown): string {\n const val = from ?? to\n if (typeof val === 'number') return 'number'\n if (typeof val === 'boolean') return 'boolean'\n if (val instanceof Date) return 'date'\n if (typeof val === 'string') {\n if (/^\\d{4}-\\d{2}-\\d{2}/.test(val)) return 'date'\n return 'text'\n }\n if (Array.isArray(val)) {\n const first = val[0]\n if (val.length > 0 && typeof first === 'object' && first !== null) {\n // Block tree: instances discriminated by `type` + `id`.\n if ('type' in first && 'id' in first) return 'blockTree'\n }\n return 'json'\n }\n if (typeof val === 'object') return 'json'\n return 'unknown'\n}\n\n/** Diff two string values and return segments (used by block-diff for string props). */\nexport function diffStringValues(from: unknown, to: unknown): DiffSegment[] | undefined {\n if (typeof from === 'string' || typeof to === 'string') {\n return diffText(stringify(from), stringify(to))\n }\n return undefined\n}\n","/**\n * Structural diff for block-tree arrays (`field.blockTree()` values).\n *\n * Operates on new-format `BlockInstance`s (`{ id, type, props, traits,\n * children }`) — the only block shape the toolkit stores. Blocks are\n * identified by their `id`. The algorithm detects:\n * - Added blocks (new id in target)\n * - Removed blocks (missing id in target)\n * - Moved blocks (same id, different position)\n * - Changed blocks (same id, different prop/trait/child values)\n * - Unchanged blocks\n */\n\nimport { diffStringValues } from './field-diff.js'\nimport type { BlockDiffEntry, PropDiff } from './types.js'\n\n/** Comparable view of a block instance. */\ninterface NormalizedBlock {\n id: string\n type: string\n /** Comparable content map: `props` + folded-in `traits` / `children`. */\n props: Record<string, unknown>\n}\n\n/**\n * Normalize a `BlockInstance` to its comparable `{ id, type, props }` view,\n * or null when the value isn't a recognizable block. `traits` and `children`\n * fold into the comparable map so trait edits and nested-tree changes surface\n * as prop diffs (`diffProps` skips JSON-equal values, so empty/unchanged ones\n * add no noise).\n */\nfunction normalizeBlock(val: unknown): NormalizedBlock | null {\n if (typeof val !== 'object' || val === null) return null\n const rec = val as Record<string, unknown>\n\n if (typeof rec.id !== 'string' || typeof rec.type !== 'string') return null\n\n const props: Record<string, unknown> = { ...((rec.props as Record<string, unknown>) ?? {}) }\n if (rec.traits !== undefined) props.traits = rec.traits\n if (rec.children !== undefined) props.children = rec.children\n return { id: rec.id, type: rec.type, props }\n}\n\n/**\n * Diff two prop objects within a single block, returning per-prop diffs.\n */\nfunction diffProps(\n fromProps: Record<string, unknown>,\n toProps: Record<string, unknown>,\n): PropDiff[] {\n const allKeys = new Set([...Object.keys(fromProps), ...Object.keys(toProps)])\n const diffs: PropDiff[] = []\n\n for (const key of allKeys) {\n const fromVal = fromProps[key]\n const toVal = toProps[key]\n\n if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {\n diffs.push({\n field: key,\n from: fromVal,\n to: toVal,\n segments: diffStringValues(fromVal, toVal),\n })\n }\n }\n\n return diffs\n}\n\n/**\n * Index a block array by id, assigning each block its position AMONG\n * recognizable blocks (not its raw array index). Values that fail\n * normalization are skipped without consuming a position, so a stray\n * non-block entry can't skew move detection for the real blocks around it.\n */\nfunction indexBlocks(blocks: unknown[]): Map<string, { block: NormalizedBlock; index: number }> {\n const map = new Map<string, { block: NormalizedBlock; index: number }>()\n let position = 0\n for (const raw of blocks) {\n const block = normalizeBlock(raw)\n if (block) {\n map.set(block.id, { block, index: position })\n position++\n }\n }\n return map\n}\n\n/**\n * Compute structural diff between two block arrays.\n *\n * Each block is identified by its id. Position changes, additions, removals,\n * and prop-level changes are all detected.\n */\nexport function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[] {\n const fromMap = indexBlocks(fromBlocks)\n const toMap = indexBlocks(toBlocks)\n\n const entries: BlockDiffEntry[] = []\n const processedIds = new Set<string>()\n\n // Walk the \"to\" array to preserve target order\n for (const [id, { block: toBlock, index: toIndex }] of toMap) {\n processedIds.add(id)\n const fromEntry = fromMap.get(id)\n\n if (!fromEntry) {\n // Added\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'added',\n toIndex,\n })\n continue\n }\n\n const fromBlock = fromEntry.block\n const fromIndex = fromEntry.index\n const propDiffs = diffProps(fromBlock.props, toBlock.props)\n const moved = fromIndex !== toIndex\n\n if (propDiffs.length === 0 && !moved) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'unchanged',\n fromIndex,\n toIndex,\n })\n } else if (propDiffs.length > 0) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'changed',\n fromIndex,\n toIndex,\n propDiffs,\n })\n } else {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'moved',\n fromIndex,\n toIndex,\n })\n }\n }\n\n // Removed blocks (in \"from\" but not in \"to\")\n for (const [id, { block, index }] of fromMap) {\n if (!processedIds.has(id)) {\n entries.push({\n blockId: id,\n blockType: block.type,\n status: 'removed',\n fromIndex: index,\n })\n }\n }\n\n return entries\n}\n","/**\n * Shared per-instance state for ContentClient feature modules.\n *\n * The package's domain modules (`versions.ts`, `drafts.ts`,\n * `translation-status.ts`, `locale-status.ts`, `layout.ts`) are written\n * as free functions that take a `ContentContext` as their first\n * argument. ContentClient itself is a thin shell that builds the\n * context once and forwards every public method to the corresponding\n * free function.\n *\n * Why free functions instead of one class with everything inline:\n * - 1000+ LoC in one class is impossible to navigate and audit.\n * - Each module has clear inputs (the context + method args) and no\n * hidden dependencies on private state.\n * - Splits cleanly along the same boundaries as the per-entity\n * capability tables (drafts, versions, locale_status, ...) — every\n * module owns exactly one capability surface.\n */\n\nimport 'server-only'\n\nimport type { Logger } from '@murumets-ee/core'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { getVersionableOptions } from '../behaviors/versionable.js'\nimport { getContentTables } from '../schema/registry.js'\n\n/**\n * Per-instance content context. Built once in `createContentContext()`\n * and threaded through every domain module's free functions.\n */\nexport interface ContentContext<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n admin: AdminClient<AllFields>\n db: PostgresJsDatabase\n versionableOptions: ReturnType<typeof getVersionableOptions>\n logger?: Logger | undefined\n}\n\n/**\n * Build a content context from an AdminClient + entity. The DB handle\n * is read off the AdminClient (it owns the read-write connection).\n */\nexport function createContentContext<AllFields extends Record<string, FieldConfig>>(\n config: {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n },\n // Lazy import to avoid a static cycle: behaviors/versionable.ts is loaded\n // by client.ts which would otherwise close the loop. Phase-2 validated\n // the static import graph; this is a private helper bundled into the\n // same chunk so the dynamic-vs-static rule doesn't apply.\n versionableOptions: ReturnType<typeof getVersionableOptions>,\n): ContentContext<AllFields> {\n return {\n entity: config.entity,\n admin: config.admin,\n db: config.admin.getDb(),\n versionableOptions,\n logger: config.logger,\n }\n}\n\n// -------------------------------------------------------------------\n// Per-capability TableClient lookups. Each returns null if the entity\n// hasn't registered the corresponding table (i.e. doesn't have the\n// behavior). ContentClient public methods translate that null into\n// either a thrown EntityCapabilityError (writes) or a falsy return\n// (reads).\n// -------------------------------------------------------------------\n\nexport function getDraftsClient(ctx: ContentContext) {\n const { drafts } = getContentTables(ctx.entity.name)\n return drafts ? drafts.makeClient(ctx.db) : null\n}\n\nexport function getVersionsClient(ctx: ContentContext) {\n const { versions } = getContentTables(ctx.entity.name)\n return versions ? versions.makeClient(ctx.db) : null\n}\n\nexport function getLocaleStatusClient(ctx: ContentContext) {\n const { localeStatus } = getContentTables(ctx.entity.name)\n return localeStatus ? localeStatus.makeClient(ctx.db) : null\n}\n","/**\n * Per-locale publish status — independent draft/published state for\n * each locale of a publishable + translatable entity.\n *\n * Capability: requires `publishable() + at least one translatable\n * field` (the `_locale_status` table is registered only for that\n * combination). Reads silently return falsy on entities without it;\n * `setLocaleStatus` throws `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { EntityCapabilityError } from './errors.js'\nimport { type ContentContext, getLocaleStatusClient } from './internal/context.js'\nimport { upsertLocaleStatusRow } from './locale-status-slot.js'\nimport { validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { LocalePublishStatus } from './types.js'\n\n/**\n * Get per-locale publish status overrides for an entity.\n * Missing locale = inherits the base entity status (no row written).\n */\nexport async function getLocaleStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<LocalePublishStatus[]> {\n const map = await getLocaleStatusBatch(ctx, [entityId])\n const entry = map.get(entityId)\n if (!entry) return []\n return Object.values(entry)\n}\n\n/**\n * Set per-locale publish status. Upserts into the locale_status\n * table — except when `locale === defaultLocale`, which updates the\n * base entity status column directly (the per-locale table only\n * stores overrides for non-default locales).\n */\nexport async function setLocaleStatus<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n validateLocale(locale)\n const config = getContentConfig()\n\n // Default locale → update base entity status column.\n if (locale === config.defaultLocale) {\n const data: Record<string, unknown> = { status }\n if (status === 'published') data.publishedAt = new Date()\n await ctx.admin.update(entityId, data as InferUpdateInput<F>)\n return\n }\n\n const client = getLocaleStatusClient(ctx)\n if (!client) {\n // Per-locale status only exists for publishable + translatable\n // entities. Calling on a non-translatable publishable (where the\n // only valid locale is the default) is a programmer error.\n throw new EntityCapabilityError(ctx.entity.name, 'localeStatus')\n }\n\n await upsertLocaleStatusRow(client, entityId, locale, status)\n}\n\n/**\n * Get per-locale publish status for multiple entities in one query.\n */\nexport async function getLocaleStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, Record<string, LocalePublishStatus>>> {\n const result = new Map<string, Record<string, LocalePublishStatus>>()\n if (entityIds.length === 0) return result\n const client = getLocaleStatusClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n let entry = result.get(eid)\n if (!entry) {\n entry = {}\n result.set(eid, entry)\n }\n entry[locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n return result\n}\n","/**\n * Locale-routed entity write shared by `publishDraft` and `restoreVersion`.\n *\n * Since SD021, the routing itself lives in ONE place —\n * `AdminClient.updateForLocale` (`@murumets-ee/entity`): default locale →\n * base row; non-default → translatable fields to `saveTranslation`,\n * `status` to the per-locale status store, non-translatable remainder to\n * the base row. This module is the thin content-side call: it supplies the\n * plugin's `defaultLocale` and a per-call locale-status writer bound to\n * THIS context's tables, so routing is deterministic regardless of how\n * `ctx.admin` was constructed.\n *\n * Lives in its own internal module because `drafts.ts` imports\n * `versions.ts` (for `createVersionSnapshot`) — exporting this from\n * either of them would close an import cycle.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { setLocaleStatus } from '../locale-status.js'\nimport { DEFAULT_LOCALE_SENTINEL } from '../locale-utils.js'\nimport { getContentConfig } from '../plugin.js'\nimport { type ContentContext, getLocaleStatusClient } from './context.js'\n\n/** Is this snapshot-key locale the default locale (base-row) one? */\nexport function isDefaultLocale(locale: string): boolean {\n return locale === DEFAULT_LOCALE_SENTINEL || locale === getContentConfig().defaultLocale\n}\n\n/**\n * `findById` options that read the same locale view the write targets —\n * default locale reads the base row, non-default reads the\n * translation-merged row.\n */\nexport function localeFindOptions(\n locale: string,\n): { defaultLocale: string } | { locale: string; defaultLocale: string } {\n const { defaultLocale } = getContentConfig()\n return isDefaultLocale(locale) ? { defaultLocale } : { locale, defaultLocale }\n}\n\n/**\n * Apply projected entity-field data to the given locale via the SD021\n * choke point. `updateData` MUST already be projected through\n * `projectToEntityFields` — this function only delegates, it does not\n * whitelist.\n */\nexport async function applyLocaleRoutedUpdate<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n updateData: Record<string, unknown>,\n locale: string,\n): Promise<InferEntityDTO<F>> {\n // Bind the status writer to THIS context's locale-status table when the\n // entity has one; otherwise let updateForLocale fall back to the base\n // status column (non-translatable publishables have only the default\n // locale).\n const hasLocaleStatus = getLocaleStatusClient(ctx) !== null\n return ctx.admin.updateForLocale(entityId, updateData as InferUpdateInput<F>, locale, {\n defaultLocale: getContentConfig().defaultLocale,\n ...(hasLocaleStatus && {\n localeStatusWriter: (id: string, loc: string, status: 'draft' | 'published') =>\n setLocaleStatus(ctx, id, loc, status),\n }),\n })\n}\n","/**\n * Field projection + delta helpers shared between drafts and versions.\n *\n * `projectToEntityFields` is the security-critical whitelist filter\n * applied before replaying stored data through `admin.update()` —\n * see audit-security-content.md M#4 (drafts/restore must not pass\n * through forbidden fields like `status: 'published'` or\n * `_workflowStatus: 'approved'`).\n *\n * `valuesEqual` and `calculateDelta` are pure utilities used by both\n * the version-snapshot delta calculator and the block-diff prop\n * comparison — collapsing 3× repeated `JSON.stringify(a) !== JSON.stringify(b)`\n * sites identified in audit-quality-content.md DRY candidates.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\n/**\n * Identity / audit / timestamp fields that exist as real columns on every\n * entity (added by `defineEntity` / `auditable()` / `timestamped()`) and\n * MUST NOT be relayed through `admin.update()` from a draft or version\n * payload. They're either server-set (`createdAt`, `createdBy`,\n * `updatedAt`, `updatedBy`) or immutable identity (`id`).\n *\n * Without this explicit deny-list, `id` would survive `projectToEntityFields`\n * because `defineEntity` injects it into `entity.allFields` and it's not\n * marked `internal: true`. AdminClient.update's Zod schema strips it\n * downstream as a final defense, but security-critical filtering should\n * live in one place — not split across the projection AND the validator.\n */\nconst PROJECTION_DENYLIST = new Set(['id', 'createdAt', 'createdBy', 'updatedAt', 'updatedBy'])\n\n/**\n * Project a draft / version payload through the entity's declared\n * field map before forwarding to `admin.update()`. Used by\n * `publishDraft()` and `restoreVersion()` — both replay stored data\n * that originated from a write, but neither should be allowed to set\n * fields the entity didn't declare.\n *\n * Drops:\n * - the audit/identity column set above (`id`, `createdAt`,\n * `createdBy`, `updatedAt`, `updatedBy`)\n * - any key starting with `_` (behavior internals managed by hooks)\n * - any field declared `internal: true` (writable only via\n * AdminClient.updateInternal — never via public CRUD or replay)\n * - any key not in `entity.allFields` (whitelist for app-defined fields)\n */\nexport function projectToEntityFields(\n entity: Entity,\n data: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = entity.allFields as Record<string, FieldConfig>\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(data)) {\n if (PROJECTION_DENYLIST.has(key)) continue\n if (key.startsWith('_')) continue\n if (!Object.hasOwn(allowed, key)) continue\n const f = allowed[key]\n if (f && (f as { internal?: boolean }).internal === true) continue\n out[key] = data[key]\n }\n return out\n}\n\n/**\n * Structural equality via canonical JSON serialization. Coarse, with\n * three known quirks worth flagging at every call site:\n *\n * 1. **Key order matters in pathological cases.** Two objects with\n * identical keys+values but different insertion order serialize\n * differently. Safe for entity field values where keys come from\n * the same field map on both sides; not safe for arbitrary user\n * JSONB blobs.\n * 2. **Dates compare equal to their ISO string.** `new Date('2026-01-01')`\n * and `'2026-01-01T00:00:00.000Z'` both stringify to\n * `\"2026-01-01T00:00:00.000Z\"`. After a round-trip through JSONB\n * storage, a previously Date-typed field will come back as a\n * string and compare equal to its origin Date — exactly what we\n * want for delta calculation.\n * 3. **`undefined` is dropped.** `{ x: undefined }` and `{}` both\n * serialize to `{}`. If you need to distinguish \"field present\n * but null\" from \"field missing\", do not use this helper.\n */\nexport function valuesEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b)\n}\n\n/**\n * Field-level delta between previous and current entity snapshots.\n * Skips `id` and any underscore-prefixed behavior-internal field.\n * Only changed keys are returned. Used by version-snapshot persistence\n * to store a delta alongside the full snapshot for compact \"what\n * changed since last version\" reads.\n */\nexport function calculateDelta(\n previous: Record<string, unknown>,\n current: Record<string, unknown>,\n): Record<string, unknown> {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(current)) {\n if (key === 'id' || key.startsWith('_')) continue\n if (!valuesEqual(previous[key], current[key])) {\n delta[key] = current[key]\n }\n }\n return delta\n}\n","/**\n * Version-history operations on a content entity.\n *\n * All functions take a {@link ContentContext} as first argument and\n * are wired into ContentClient via thin delegation in `client.ts`.\n *\n * Capability: requires the entity to declare `versionable()`. Read\n * methods return falsy when the entity doesn't support versions\n * (defensive, supports mixed-entity loops in the dashboard); write\n * methods throw `EntityCapabilityError` mapped to HTTP 400 by admin-ui.\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO } from '@murumets-ee/entity/admin'\nimport { eq } from 'drizzle-orm'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { EntityCapabilityError } from './errors.js'\nimport { applyLocaleRoutedUpdate } from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getVersionsClient } from './internal/context.js'\nimport { calculateDelta, projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport type { VersionEntry } from './types.js'\n\n/**\n * List versions for an entity (newest first), filtered by locale.\n * Uses '_' as the locale value for default-locale snapshots.\n */\nexport async function getVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n options?: { limit?: number; offset?: number; locale?: string },\n): Promise<VersionEntry<InferEntityDTO<F>>[]> {\n if (options?.locale !== undefined) validateLocale(options.locale)\n const client = getVersionsClient(ctx)\n if (!client) return []\n\n const rows = await client.findMany({\n where: { entityId, locale: options?.locale ?? DEFAULT_LOCALE_SENTINEL },\n orderBy: [{ column: 'version', dir: 'desc' }],\n ...(options?.limit !== undefined ? { limit: options.limit } : {}),\n ...(options?.offset !== undefined ? { offset: options.offset } : {}),\n })\n return rows as VersionEntry<InferEntityDTO<F>>[]\n}\n\n/**\n * Get a specific version snapshot.\n */\nexport async function getVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<VersionEntry<InferEntityDTO<F>> | null> {\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, version, locale })\n return (row ?? null) as VersionEntry<InferEntityDTO<F>> | null\n}\n\n/**\n * Compare two versions and return field-level diff.\n *\n * Returns structured diffs with word-level segments for text fields\n * and structural block diffs for blocks fields.\n */\nexport async function compareVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n v1: number,\n v2: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n fromVersion: VersionEntry<InferEntityDTO<F>>\n toVersion: VersionEntry<InferEntityDTO<F>>\n} | null> {\n validateLocale(locale)\n const [version1, version2] = await Promise.all([\n getVersion(ctx, entityId, v1, locale),\n getVersion(ctx, entityId, v2, locale),\n ])\n if (!version1 || !version2) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const diffs = diffFields(version1.data, version2.data, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n return { diffs, fromVersion: version1, toVersion: version2 }\n}\n\n/**\n * Restore an entity to a previous version. Replays the snapshot data\n * through the locale-routed writer, projected through `entity.allFields`\n * so forbidden fields (workflow internals, behavior-managed underscore\n * fields, fields the entity declared `internal: true`) cannot leak.\n *\n * Snapshots are locale-keyed; the restore reads the snapshot for the\n * given locale and writes it back to the same locale's home —\n * translations for non-default locales, the base row for the default.\n */\nexport async function restoreVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getVersionsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'versions')\n }\n const versionData = await getVersion(ctx, entityId, version, locale)\n if (!versionData) {\n throw new Error(`Version ${version} not found for entity ${entityId}`)\n }\n const data = versionData.data as Record<string, unknown>\n const updateData = projectToEntityFields(ctx.entity, data)\n return applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n}\n\n/**\n * Enforce maxPerDoc retention by deleting oldest versions for a locale.\n */\nexport async function pruneVersions(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<void> {\n validateLocale(locale)\n if (!ctx.versionableOptions) return\n const client = getVersionsClient(ctx)\n if (!client) return\n\n const maxPerDoc = ctx.versionableOptions.maxPerDoc\n\n // Get IDs of versions to keep (newest N for this locale). The findMany\n // is capped at `limit: maxPerDoc`, so `keepVersions.length` is\n // `min(totalRows, maxPerDoc)` — never larger than the limit.\n const keepVersions = await client.findMany({\n where: { entityId, locale },\n orderBy: [{ column: 'version', dir: 'desc' }],\n limit: maxPerDoc,\n })\n\n // Strictly under the limit ⇒ totalRows < maxPerDoc ⇒ nothing to prune.\n // At the limit (length == maxPerDoc) we cannot distinguish \"exactly\n // maxPerDoc\" from \"more than maxPerDoc\" without another count query,\n // so we always run the DELETE — Postgres correctly removes 0-or-more\n // older rows. Better one always-cheap DELETE per prune than a guard\n // round trip on every call.\n if (keepVersions.length < maxPerDoc) return\n\n const keepIds = keepVersions.map((v) => v.id as string)\n\n // Delete everything for this entity+locale NOT in the keep list.\n await client.deleteMany({\n entityId,\n locale,\n id: { notIn: keepIds },\n })\n}\n\n/**\n * Persist a new version snapshot. Internal helper used by\n * `createVersionSnapshot` and ContentClient.update / .create.\n *\n * Attribution: ONLY the explicit `user` parameter is trusted. The\n * fallback chain that previously read `data.updatedBy` /\n * `data.createdBy` was an audit-log integrity gap (see audit\n * MEDIUM #1) — version history must never absorb a forged actor from\n * the request payload.\n */\nexport async function saveVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n data: Record<string, unknown>,\n delta: Record<string, unknown> | null,\n isAutosave: boolean,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<number | undefined> {\n // All current callers pass a pre-validated locale (or the sentinel).\n // Defensive check at the boundary so future callers — or anyone who\n // exposes this helper publicly — still get the validation.\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return\n\n // Get the next version number for this locale (max existing + 1).\n const [maxRow] = await client.aggregate<{ maxVersion: number | null }>({\n select: { maxVersion: { fn: 'max', column: 'version' } },\n where: { entityId, locale },\n })\n const version = Number(maxRow?.maxVersion ?? 0) + 1\n const status = (data.status as string) ?? null\n\n // Autosave: update the existing autosave row at the same version\n // number, preserving \"autosaves land on the latest slot\" semantics.\n if (isAutosave) {\n const existing = await client.findOne({ entityId, isAutosave: true, version, locale })\n if (existing) {\n await client.update(\n { id: existing.id as string },\n { data, delta, status, createdAt: new Date() },\n )\n return version\n }\n }\n\n await client.insert({\n entityId,\n version,\n locale,\n data,\n delta,\n status,\n createdBy: user?.id ?? null,\n createdByName: user?.name ?? null,\n isAutosave,\n })\n return version\n}\n\n/**\n * Create a version snapshot for an entity after an external update.\n * Use this when the update was performed by AdminClient directly\n * (e.g. the admin API handler) rather than through ContentClient.update().\n *\n * @param locale - The locale being edited. Use undefined or '_' for the default locale.\n * @param user - The user who made the change (id + name for display).\n */\nexport async function createVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n): Promise<void> {\n if (locale !== undefined) validateLocale(locale)\n if (!ctx.versionableOptions) return\n const snapshotLocale = locale ?? DEFAULT_LOCALE_SENTINEL\n const delta = previousData ? calculateDelta(previousData, currentData) : null\n const version = await saveVersionSnapshot(\n ctx,\n entityId,\n currentData,\n delta,\n false,\n snapshotLocale,\n user,\n )\n\n // Update _version on the entity to match (default locale only).\n if (version !== undefined && snapshotLocale === DEFAULT_LOCALE_SENTINEL) {\n await writeVersionColumn(ctx, entityId, version)\n }\n\n await pruneVersions(ctx, entityId, snapshotLocale)\n}\n\n/**\n * Write the `_version` column on the entity row.\n *\n * KNOWN EXCEPTION: this bypasses AdminClient and goes straight to the\n * entity pgTable via `schemaRegistry` + direct Drizzle. Using\n * `admin.update()` would retrigger the `versionable.beforeUpdate` hook\n * and loop indefinitely. The only ContentClient path that still writes\n * to an entity table directly.\n */\nexport async function writeVersionColumn(\n ctx: ContentContext,\n entityId: string,\n version: number,\n): Promise<void> {\n const table = schemaRegistry.get(ctx.entity.name)\n if (!table) return\n await ctx.db.update(table).set({ _version: version }).where(eq(table.id, entityId))\n}\n","/**\n * Draft-overlay operations.\n *\n * Capability: requires the entity to declare `publishable()`. Reads\n * (`getDraft`, `hasDraft`, `countDrafts`, `hasDraftBatch`) silently\n * return falsy when the entity doesn't support drafts; writes\n * (`saveDraft`, `publishDraft`) throw `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { DraftNotFoundError, EntityCapabilityError } from './errors.js'\nimport {\n applyLocaleRoutedUpdate,\n localeFindOptions,\n} from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getDraftsClient } from './internal/context.js'\nimport { projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { DraftsTable } from './schema/drafts.js'\nimport type { DraftEntry } from './types.js'\nimport { createVersionSnapshot } from './versions.js'\n\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\n\nfunction toDraftEntry(row: DraftRow): DraftEntry {\n return {\n id: row.id,\n entityId: row.entityId,\n locale: row.locale,\n data: row.data,\n createdBy: row.createdBy,\n createdByName: row.createdByName ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }\n}\n\n/**\n * Save (upsert) a draft for an entity+locale. One draft per\n * entity+locale — overwrites any existing draft.\n */\nexport async function saveDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n data: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>>> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const now = new Date()\n const createdBy = user?.id ?? ''\n const createdByName = user?.name ?? null\n\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: data as Record<string, unknown>,\n createdBy,\n createdByName,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only update the mutable fields — preserve createdBy/createdAt.\n set: { data: data as Record<string, unknown>, updatedAt: now },\n },\n )\n\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * Get the draft for an entity+locale, or null if none exists (or the\n * entity doesn't support drafts).\n */\nexport async function getDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, locale })\n return row ? (toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>) : null\n}\n\n/**\n * Delete (discard) a draft for an entity+locale. Returns whether\n * anything was deleted; safely no-ops on entities without drafts.\n */\nexport async function deleteDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n const deleted = await client.deleteMany({ entityId, locale })\n return deleted.length > 0\n}\n\n/**\n * Publish a draft: project the draft data through the entity field\n * whitelist, apply it to the locale the draft belongs to, then delete\n * the draft and (if versionable) record a version snapshot.\n *\n * Locale routing mirrors the admin PATCH handler (and the taxonomy fix\n * in 10a7cc2b): a default-locale draft writes the base row; a\n * non-default-locale draft routes translatable fields to\n * `saveTranslation` (→ `{entity}_translations`), `status` to the\n * per-locale status table, and only non-translatable fields (which have\n * no per-locale home) to the base row. Without this split, publishing\n * an `et` draft overwrote the default-locale base row wholesale.\n */\nexport async function publishDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getDraftsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n }\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) {\n throw new DraftNotFoundError(entityId, locale)\n }\n\n // Get previous state for the version delta — locale-merged, so the\n // delta compares against what this locale's readers actually saw.\n let previousState: InferEntityDTO<F> | null = null\n if (ctx.versionableOptions) {\n previousState = await ctx.admin.findById(entityId, localeFindOptions(locale))\n }\n\n // Apply draft data, projecting through the entity's field whitelist:\n // anything not declared in `entity.allFields` (or marked `internal:\n // true`) is silently dropped. NB `status` IS a declared publishable()\n // field and survives the projection — the publish-permission gate lives\n // in the admin route (`requirePermission(…, 'publish')` before\n // publishDraft is called), not here.\n const draftData = draft.data as Record<string, unknown>\n // \"Publish\" means MAKE LIVE. Block-editor drafts carry only content\n // (layout/theme/meta) — without this merge nothing ever flipped `status`,\n // so publishing promoted the content while the document stayed a draft\n // (badge \"Draft\", second click 409 — PR 23 user-smoke find). A draft that\n // explicitly carries `status` (form-save drafts can) wins; the\n // publishable() behavior auto-sets `publishedAt` on the transition.\n // `!== undefined` rather than `in` — a programmatic saveDraft can store\n // `status: undefined` (JSON can't), and that must not skip the merge.\n const dataWithStatus =\n draftData.status !== undefined ? draftData : { ...draftData, status: 'published' }\n const updateData = projectToEntityFields(ctx.entity, dataWithStatus)\n const result = await applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n\n await deleteDraft(ctx, entityId, locale)\n\n if (ctx.versionableOptions) {\n await createVersionSnapshot(ctx, entityId, result, previousState, locale, user)\n }\n\n return result\n}\n\n/**\n * Quick existence check for list badges — does a draft exist?\n */\nexport async function hasDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return false\n return client.exists({ entityId, locale })\n}\n\n/**\n * Count drafts for this entity type, optionally filtered by locale.\n * Single indexed COUNT — used by dashboard widgets.\n */\nexport async function countDrafts(\n ctx: ContentContext,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<number> {\n validateLocale(locale)\n const client = getDraftsClient(ctx)\n if (!client) return 0\n return client.count({ locale })\n}\n\n/**\n * Check draft existence for multiple entities in one query. Returns\n * a set of entity IDs that have a draft for `locale`.\n */\nexport async function hasDraftBatch(\n ctx: ContentContext,\n entityIds: string[],\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<Set<string>> {\n validateLocale(locale)\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n const client = getDraftsClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n return result\n}\n\n/**\n * Compare the current draft with the live entity. Returns field-level\n * diffs between the published data and the draft (only for fields\n * present in the draft — missing fields are NOT \"changed to empty\").\n */\nexport async function compareDraftWithCurrent<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n current: InferEntityDTO<F>\n draft: DraftEntry<InferUpdateInput<F>>\n} | null> {\n validateLocale(locale)\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) return null\n\n // Fetch the live entity with the same locale context as the draft.\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions =\n locale === DEFAULT_LOCALE_SENTINEL ? { defaultLocale } : { locale, defaultLocale }\n const current = await ctx.admin.findById(entityId, findOptions)\n if (!current) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const currentRecord = current as Record<string, unknown>\n const draftRecord = draft.data as Record<string, unknown>\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draftRecord)) {\n currentSubset[key] = currentRecord[key]\n }\n const diffs = diffFields(currentSubset, draftRecord, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Translation status reporting — per-locale completion of translatable\n * fields for one entity, batches of entities, or the whole entity type.\n *\n * Capability: requires the entity to have at least one translatable\n * field. All methods return falsy / empty when the entity has no\n * translatable fields (read-only and defensive).\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { inArray } from 'drizzle-orm'\nimport type { ContentContext } from './internal/context.js'\nimport { getContentConfig } from './plugin.js'\nimport type { TranslationReport, TranslationStatus } from './types.js'\n\n/**\n * For one entity, compute per-locale completion status across every\n * translatable field. Default-locale rows are skipped.\n */\nfunction computeStatusForEntity(\n translatableFields: string[],\n translatedByLocale: Map<string, Record<string, unknown>> | undefined,\n locales: { code: string }[],\n defaultLocale: string,\n): TranslationStatus {\n const status: TranslationStatus = {}\n for (const locale of locales) {\n if (locale.code === defaultLocale) continue\n const translated = translatedByLocale?.get(locale.code) ?? {}\n const missing: string[] = []\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatableFields.length - missing.length,\n missing,\n }\n }\n return status\n}\n\n/**\n * Get translation status for an entity — per-locale completion report.\n */\nexport async function getTranslationStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<TranslationStatus> {\n // Single-entity is the batch path with one ID — keeps the two\n // codepaths from drifting.\n const map = await getTranslationStatusBatch(ctx, [entityId])\n return map.get(entityId) ?? {}\n}\n\n/**\n * Get translation status for multiple entities in a single query.\n * O(1) DB roundtrips regardless of `entityIds.length`.\n */\nexport async function getTranslationStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const config = getContentConfig()\n\n const translatableFields = Object.entries(ctx.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK\n // refs); accessed via schemaRegistry + direct Drizzle here.\n const translationTable = schemaRegistry.get(`${ctx.entity.name}_translations`)\n if (!translationTable) return result\n\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(inArray(translationTable.entityId, entityIds))\n\n // Group: entityId → locale → { fieldName: value }\n const translationsByEntity = new Map<string, Map<string, Record<string, unknown>>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const eid = row.entityId as string\n const locale = row.locale as string\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n let entry = translationsByEntity.get(eid)\n if (!entry) {\n entry = new Map()\n translationsByEntity.set(eid, entry)\n }\n entry.set(locale, fieldValues)\n }\n\n for (const entityId of entityIds) {\n result.set(\n entityId,\n computeStatusForEntity(\n translatableFields,\n translationsByEntity.get(entityId),\n config.locales,\n config.defaultLocale,\n ),\n )\n }\n\n return result\n}\n\n/**\n * Roll up translation status across every entity of this type.\n *\n * Defaults to the first non-default configured locale; pass\n * `options.locale` to target a specific one. Returns counts of\n * complete / partial / missing entities for that locale.\n */\nexport async function getTranslationReport(\n ctx: ContentContext,\n options?: { locale?: string },\n): Promise<TranslationReport> {\n const config = getContentConfig()\n const targetLocale =\n options?.locale ?? config.locales.find((l) => l.code !== config.defaultLocale)?.code\n if (!targetLocale) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n const entities = await ctx.admin.findMany()\n if (entities.length === 0) return { total: 0, complete: 0, partial: 0, missing: 0 }\n\n // Single batched query covers every entity at once.\n const statusMap = await getTranslationStatusBatch(\n ctx,\n entities.map((e) => e.id),\n )\n\n let complete = 0\n let partial = 0\n let missing = 0\n for (const entity of entities) {\n const status = statusMap.get(entity.id)\n const localeStatus = status?.[targetLocale]\n if (!localeStatus || localeStatus.translated === 0) missing++\n else if (localeStatus.complete) complete++\n else partial++\n }\n\n return { total: entities.length, complete, partial, missing }\n}\n","/**\n * ContentClient — thin shell over per-feature modules.\n *\n * Wraps AdminClient with version history (`./versions.ts`), draft\n * overlays (`./drafts.ts`), translation status reporting\n * (`./translation-status.ts`), per-locale publish status\n * (`./locale-status.ts`), and block-layout operations (`./layout.ts`).\n *\n * The class is generic over the entity's `AllFields` map: when\n * constructed with a typed entity, every method's input/output is\n * inferred end-to-end. When constructed with a runtime `Entity`\n * (admin-ui's heterogeneous entity loops), the generic defaults to\n * `Record<string, FieldConfig>` and methods accept/return the loose\n * shape — same behavior as the pre-generic version.\n *\n * Usage:\n * import { createContentClient } from '@murumets-ee/content/client'\n * const pages = createContentClient(Page)\n * await pages.create({ title: 'Hello', layout: [...] })\n */\n\nimport 'server-only'\n\nimport { getApp, type Logger } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type {\n AdminClient,\n FindManyOptions,\n InferCreateInput,\n InferEntityDTO,\n InferUpdateInput,\n} from '@murumets-ee/entity/admin'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport {\n compareDraftWithCurrent,\n countDrafts,\n deleteDraft,\n getDraft,\n hasDraft,\n hasDraftBatch,\n publishDraft,\n saveDraft,\n} from './drafts.js'\nimport { type ContentContext, createContentContext } from './internal/context.js'\nimport { calculateDelta } from './internal/projection.js'\nimport { getLocaleStatus, getLocaleStatusBatch, setLocaleStatus } from './locale-status.js'\nimport {\n getTranslationReport,\n getTranslationStatus,\n getTranslationStatusBatch,\n} from './translation-status.js'\nimport {\n compareVersions,\n createVersionSnapshot,\n getVersion,\n getVersions,\n pruneVersions,\n restoreVersion,\n saveVersionSnapshot,\n writeVersionColumn,\n} from './versions.js'\n\nexport interface ContentClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n}\n\nexport class ContentClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private ctx: ContentContext<AllFields>\n\n constructor(config: ContentClientConfig<AllFields>) {\n const versionableOptions = getVersionableOptions(config.entity.behaviors)\n this.ctx = createContentContext(config, versionableOptions)\n }\n\n // -----------------------------------------------------------------\n // CRUD (delegated to AdminClient + version snapshots)\n // -----------------------------------------------------------------\n\n async create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n const result = await this.ctx.admin.create(data)\n if (this.ctx.versionableOptions) {\n await saveVersionSnapshot(this.ctx, result.id, result, null, false)\n }\n return result\n }\n\n findById(id: string, options?: { locale?: string }): Promise<InferEntityDTO<AllFields> | null> {\n return this.ctx.admin.findById(id, options)\n }\n\n findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n return this.ctx.admin.findMany(options)\n }\n\n /**\n * Base-row update + version snapshot. Subject to AdminClient's\n * cross-locale write guard (SD021): under a non-default request-context\n * locale, translatable fields throw `CrossLocaleWriteError` — that's\n * enforcement, not a gap. Locale-belonging writes go through the draft\n * flow (`saveDraft` → `publishDraft`) or `admin.updateForLocale` directly.\n */\n async update(id: string, data: InferUpdateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n let previousState: InferEntityDTO<AllFields> | null = null\n if (this.ctx.versionableOptions) {\n previousState = await this.ctx.admin.findById(id)\n }\n\n const result = await this.ctx.admin.update(id, data)\n\n if (this.ctx.versionableOptions && previousState) {\n const delta = calculateDelta(previousState, result)\n const version = await saveVersionSnapshot(this.ctx, id, result, delta, false)\n if (version !== undefined) {\n await writeVersionColumn(this.ctx, id, version)\n await pruneVersions(this.ctx, id)\n return { ...result, _version: version } as InferEntityDTO<AllFields>\n }\n await pruneVersions(this.ctx, id)\n }\n\n return result\n }\n\n delete(id: string): Promise<unknown> {\n return this.ctx.admin.delete(id)\n }\n\n // -----------------------------------------------------------------\n // Translation delegation\n // -----------------------------------------------------------------\n\n saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n return this.ctx.admin.saveTranslation(entityId, locale, translations)\n }\n\n deleteTranslation(entityId: string, locale?: string) {\n return this.ctx.admin.deleteTranslation(entityId, locale)\n }\n\n // -----------------------------------------------------------------\n // Version history\n // -----------------------------------------------------------------\n\n getVersions(entityId: string, options?: { limit?: number; offset?: number; locale?: string }) {\n return getVersions(this.ctx, entityId, options)\n }\n\n getVersion(entityId: string, version: number, locale?: string) {\n return getVersion(this.ctx, entityId, version, locale)\n }\n\n compareVersions(entityId: string, v1: number, v2: number, locale?: string) {\n return compareVersions(this.ctx, entityId, v1, v2, locale)\n }\n\n restoreVersion(entityId: string, version: number, locale?: string) {\n return restoreVersion(this.ctx, entityId, version, locale)\n }\n\n pruneVersions(entityId: string, locale?: string) {\n return pruneVersions(this.ctx, entityId, locale)\n }\n\n createVersionSnapshot(\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return createVersionSnapshot(this.ctx, entityId, currentData, previousData, locale, user)\n }\n\n // -----------------------------------------------------------------\n // Translation status\n // -----------------------------------------------------------------\n\n getTranslationStatus(entityId: string) {\n return getTranslationStatus(this.ctx, entityId)\n }\n\n getTranslationStatusBatch(entityIds: string[]) {\n return getTranslationStatusBatch(this.ctx, entityIds)\n }\n\n getTranslationReport(options?: { locale?: string }) {\n return getTranslationReport(this.ctx, options)\n }\n\n // -----------------------------------------------------------------\n // Per-locale publish status\n // -----------------------------------------------------------------\n\n getLocaleStatus(entityId: string) {\n return getLocaleStatus(this.ctx, entityId)\n }\n\n setLocaleStatus(entityId: string, locale: string, status: 'draft' | 'published') {\n return setLocaleStatus(this.ctx, entityId, locale, status)\n }\n\n getLocaleStatusBatch(entityIds: string[]) {\n return getLocaleStatusBatch(this.ctx, entityIds)\n }\n\n // -----------------------------------------------------------------\n // Drafts\n // -----------------------------------------------------------------\n\n saveDraft(\n entityId: string,\n data: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return saveDraft(this.ctx, entityId, data, locale, user)\n }\n\n getDraft(entityId: string, locale?: string) {\n return getDraft(this.ctx, entityId, locale)\n }\n\n deleteDraft(entityId: string, locale?: string) {\n return deleteDraft(this.ctx, entityId, locale)\n }\n\n publishDraft(entityId: string, locale?: string, user?: { id: string; name?: string }) {\n return publishDraft(this.ctx, entityId, locale, user)\n }\n\n hasDraft(entityId: string, locale?: string) {\n return hasDraft(this.ctx, entityId, locale)\n }\n\n countDrafts(locale?: string) {\n return countDrafts(this.ctx, locale)\n }\n\n hasDraftBatch(entityIds: string[], locale?: string) {\n return hasDraftBatch(this.ctx, entityIds, locale)\n }\n\n compareDraftWithCurrent(entityId: string, locale?: string) {\n return compareDraftWithCurrent(this.ctx, entityId, locale)\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity. Must be called\n * after `createApp()`. Generic flows from the entity definition:\n * `createContentClient(Article)` returns a `ContentClient<typeof\n * Article['allFields']>` typed end-to-end.\n */\nexport function createContentClient<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n): ContentClient<AllFields> {\n const admin = createAdminClient(entity)\n return new ContentClient<AllFields>({\n admin,\n entity,\n logger: getApp().logger.child({ content: entity.name }),\n })\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/entities/site-layout.ts"],"mappings":";;;;;;AAyDA
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/entities/site-layout.ts"],"mappings":";;;;;;AAyDA;;;;;;;;;AAiBa;AAqBb;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAtCiB,uBAAA;;;;;;EAMf,QAAA;;;;;;;EAOA,UAAA;;EAEA,WAAA;;EAEA,WAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBc,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
|
package/dist/entities/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{n as e}from"../versionable-
|
|
1
|
+
import{n as e}from"../versionable-Br_G5VGR.mjs";import{a as t}from"../workflowable-BufrZXu9.mjs";import{auditable as n,defineEntity as r,field as i,publishable as a}from"@murumets-ee/entity";function o(o){let s=o?.viewRole??`editor`,c=o?.updateRole??`admin`,l=o?.publishRole??`admin`,u=o?.maxVersions??50;return r({name:`site_layout`,kind:`singleton`,fields:{slug:i.text({required:!0,default:`default`,indexed:!0}),label:i.text({translatable:!0}),headerBlocks:i.json({default:()=>[]}),footerBlocks:i.json({default:()=>[]}),beforeContentBlocks:i.json({default:()=>[]}),afterContentBlocks:i.json({default:()=>[]}),siteName:i.text({translatable:!0}),siteLogo:i.media(),siteFavicon:i.media()},behaviors:[n(),a(),e({maxPerDoc:u}),t()],scope:`global`,access:{view:s,create:l,update:c,delete:l},admin:{icon:`layout`,description:`Shared header / footer / pre / post-content blocks rendered on every page that does not override them.`,rootFields:[`slug`,`label`]}})}export{o as siteLayoutEntity};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/entities/site-layout.ts"],"sourcesContent":["/**\n * `site_layout` — the singleton document entity that holds site-wide\n * shared content (header, footer, optional before/after-content regions).\n *\n * Phase 05 §2 defines this as a `kind: 'singleton'` entity with one\n * versionable row per (site, locale). The render-time composer\n * (`composeMultiScopeDocument`) joins `site_layout` blocks with the\n * active page's blocks before applying overrides; the result is the\n * page's true rendered shape.\n *\n * The entity ships as a factory function so apps wire it into their\n * `entities` array without us hard-coding the per-app `access` rules:\n *\n * @example\n * ```ts\n * // apps/.../entities/site-layout.ts\n * import { siteLayoutEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteLayout = siteLayoutEntity()\n * ```\n *\n * Plugin contributions (PR 12): the admin route is auto-mounted by the\n * blocks plugin when an entity declares the marker symbol. Permissions\n * — `site_layout:view` (editor), `site_layout:update` (admin),\n * `site_layout:publish` (admin) — flow from the `access` map below;\n * admins can override via per-customer permission catalog.\n *\n * Behaviors layered onto the entity:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — draft / published lifecycle (publishing site_layout\n * affects every page that doesn't override it)\n * - `versionable({ maxPerDoc: 50 })` — per-locale history; longer\n * retention than pages because site-layout edits are higher-stakes\n * - `workflowable()` — same approval gates as pages (so the same\n * review surface applies)\n * - `withOpLog({ retentionDays: 30 })` — longer op retention than\n * pages; site-layout undo windows tend to be longer because changes\n * are less frequent + higher-impact\n *\n * NOTE on singleton enforcement: `kind: 'singleton'` is metadata-only\n * at the framework level — there's no runtime \"at most one row\" gate.\n * For v1 PR 12 we rely on (a) seed/wizard creating the one row and\n * (b) the admin UI hiding \"Create new\" for singleton entities. A\n * future hardening PR will add generic runtime enforcement to\n * `AdminClient.create`.\n */\n\nimport { auditable, publishable } from '@murumets-ee/entity'\nimport { defineEntity, field } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\nimport { workflowable } from '../behaviors/workflowable.js'\n\n/**\n * Optional overrides apps can apply when calling the factory. The\n * defaults match phase 05 §7 — editor reads, admin writes / publishes.\n */\nexport interface SiteLayoutEntityOptions {\n /**\n * Roles allowed to read site_layout. Default `'editor'`. The admin\n * sidebar still gates on `site_layout:view`, but lowering this to\n * `'public'` lets headless API consumers query without auth (rare).\n */\n viewRole?: string\n /**\n * Roles allowed to mutate site_layout. Default `'admin'` — high\n * blast radius. Override to `'editor'` for trusted teams; the per-\n * customer permission catalog (#357) is the right configuration\n * surface for per-deployment relaxation.\n */\n updateRole?: string\n /** Roles allowed to publish. Default `'admin'`. */\n publishRole?: string\n /** Override the version-retention cap. Default 50 versions per (site, locale). */\n maxVersions?: number\n}\n\n/**\n * Build the `site_layout` entity. Apps add the returned entity to their\n * `defineLumiConfig({ entities: [...] })` list; the blocks plugin\n * auto-mounts the admin route + emits permission catalog entries.\n *\n * Returns a fresh entity per call — multiple calls in the same process\n * produce structurally-identical but referentially-distinct entities.\n * That's fine in practice; the framework deduplicates by entity name at\n * plugin merge time.\n *\n * The return type is intentionally INFERRED (no annotation) so the\n * `'site_layout'` name stays as a string literal in the caller's\n * `Entity<…, 'site_layout', …>` view. Annotating it as bare `Entity`\n * would widen N to `string`, which propagates to the resolved-\n * permissions union and re-introduces a `${string}:view` template\n * (caught by the wide-template guards in\n * `apps/admin-playground/scripts/typed-permissions-demo.ts`).\n */\nexport function siteLayoutEntity(options?: SiteLayoutEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'admin'\n const publishRole = options?.publishRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 50\n\n return defineEntity({\n name: 'site_layout',\n kind: 'singleton',\n fields: {\n // Singleton-friendly key — apps may pre-seed a row with a known id\n // (e.g. 'default') so the admin route + composer can find it\n // without scanning. Translatable: false so the slot itself\n // doesn't fork per-locale (the BLOCKS inside each region fork\n // per-locale via versionable's chain, but the slot id is global).\n slug: field.text({\n required: true,\n default: 'default',\n indexed: true,\n }),\n label: field.text({ translatable: true }),\n // Shared content regions — each is its own block array. Validated\n // by the blocks plugin's `validateBlockTree` hook (PR 4).\n headerBlocks: field.json({ default: () => [] }),\n footerBlocks: field.json({ default: () => [] }),\n // Optional pre/post-content regions per phase 05 §2.\n beforeContentBlocks: field.json({ default: () => [] }),\n afterContentBlocks: field.json({ default: () => [] }),\n // Site metadata that some blocks might reference.\n siteName: field.text({ translatable: true }),\n siteLogo: field.media(),\n siteFavicon: field.media(),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n workflowable(),\n // withOpLog ships from `@murumets-ee/blocks/server` — apps that\n // want the block-editor surface for site_layout should layer it\n // onto the entity at app-config time (matching how the playground\n // wires page-with-blocks). Keeping it out of the factory keeps the\n // content package's import direction one-way (content doesn't\n // depend on blocks).\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Singleton — apps usually seed the row; `create` admin gating\n // prevents accidental \"second row\" but stays open for the seed\n // path (which runs as the system role).\n create: publishRole,\n update: updateRole,\n delete: publishRole,\n },\n admin: {\n icon: 'layout',\n description:\n 'Shared header / footer / pre / post-content blocks rendered on every page that does not override them.',\n rootFields: ['slug', 'label'],\n },\n })\n}\n"],"mappings":"+LA+FA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,QACpC,EAAc,GAAS,aAAe,QACtC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cACN,KAAM,YACN,OAAQ,CAMN,KAAM,EAAM,KAAK,CACf,SAAU,GACV,QAAS,UACT,QAAS,
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/entities/site-layout.ts"],"sourcesContent":["/**\n * `site_layout` — the singleton document entity that holds site-wide\n * shared content (header, footer, optional before/after-content regions).\n *\n * Phase 05 §2 defines this as a `kind: 'singleton'` entity with one\n * versionable row per (site, locale). The render-time composer\n * (`composeMultiScopeDocument`) joins `site_layout` blocks with the\n * active page's blocks before applying overrides; the result is the\n * page's true rendered shape.\n *\n * The entity ships as a factory function so apps wire it into their\n * `entities` array without us hard-coding the per-app `access` rules:\n *\n * @example\n * ```ts\n * // apps/.../entities/site-layout.ts\n * import { siteLayoutEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteLayout = siteLayoutEntity()\n * ```\n *\n * Plugin contributions (PR 12): the admin route is auto-mounted by the\n * blocks plugin when an entity declares the marker symbol. Permissions\n * — `site_layout:view` (editor), `site_layout:update` (admin),\n * `site_layout:publish` (admin) — flow from the `access` map below;\n * admins can override via per-customer permission catalog.\n *\n * Behaviors layered onto the entity:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — draft / published lifecycle (publishing site_layout\n * affects every page that doesn't override it)\n * - `versionable({ maxPerDoc: 50 })` — per-locale history; longer\n * retention than pages because site-layout edits are higher-stakes\n * - `workflowable()` — same approval gates as pages (so the same\n * review surface applies)\n * - `withOpLog({ retentionDays: 30 })` — longer op retention than\n * pages; site-layout undo windows tend to be longer because changes\n * are less frequent + higher-impact\n *\n * NOTE on singleton enforcement: `kind: 'singleton'` is metadata-only\n * at the framework level — there's no runtime \"at most one row\" gate.\n * For v1 PR 12 we rely on (a) seed/wizard creating the one row and\n * (b) the admin UI hiding \"Create new\" for singleton entities. A\n * future hardening PR will add generic runtime enforcement to\n * `AdminClient.create`.\n */\n\nimport { auditable, publishable } from '@murumets-ee/entity'\nimport { defineEntity, field } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\nimport { workflowable } from '../behaviors/workflowable.js'\n\n/**\n * Optional overrides apps can apply when calling the factory. The\n * defaults match phase 05 §7 — editor reads, admin writes / publishes.\n */\nexport interface SiteLayoutEntityOptions {\n /**\n * Roles allowed to read site_layout. Default `'editor'`. The admin\n * sidebar still gates on `site_layout:view`, but lowering this to\n * `'public'` lets headless API consumers query without auth (rare).\n */\n viewRole?: string\n /**\n * Roles allowed to mutate site_layout. Default `'admin'` — high\n * blast radius. Override to `'editor'` for trusted teams; the per-\n * customer permission catalog (#357) is the right configuration\n * surface for per-deployment relaxation.\n */\n updateRole?: string\n /** Roles allowed to publish. Default `'admin'`. */\n publishRole?: string\n /** Override the version-retention cap. Default 50 versions per (site, locale). */\n maxVersions?: number\n}\n\n/**\n * Build the `site_layout` entity. Apps add the returned entity to their\n * `defineLumiConfig({ entities: [...] })` list; the blocks plugin\n * auto-mounts the admin route + emits permission catalog entries.\n *\n * Returns a fresh entity per call — multiple calls in the same process\n * produce structurally-identical but referentially-distinct entities.\n * That's fine in practice; the framework deduplicates by entity name at\n * plugin merge time.\n *\n * The return type is intentionally INFERRED (no annotation) so the\n * `'site_layout'` name stays as a string literal in the caller's\n * `Entity<…, 'site_layout', …>` view. Annotating it as bare `Entity`\n * would widen N to `string`, which propagates to the resolved-\n * permissions union and re-introduces a `${string}:view` template\n * (caught by the wide-template guards in\n * `apps/admin-playground/scripts/typed-permissions-demo.ts`).\n */\nexport function siteLayoutEntity(options?: SiteLayoutEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'admin'\n const publishRole = options?.publishRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 50\n\n return defineEntity({\n name: 'site_layout',\n kind: 'singleton',\n fields: {\n // Singleton-friendly key — apps may pre-seed a row with a known id\n // (e.g. 'default') so the admin route + composer can find it\n // without scanning. Translatable: false so the slot itself\n // doesn't fork per-locale (the BLOCKS inside each region fork\n // per-locale via versionable's chain, but the slot id is global).\n slug: field.text({\n required: true,\n default: 'default',\n indexed: true,\n }),\n label: field.text({ translatable: true }),\n // Shared content regions — each is its own block array. Validated\n // by the blocks plugin's `validateBlockTree` hook (PR 4).\n headerBlocks: field.json({ default: () => [] }),\n footerBlocks: field.json({ default: () => [] }),\n // Optional pre/post-content regions per phase 05 §2.\n beforeContentBlocks: field.json({ default: () => [] }),\n afterContentBlocks: field.json({ default: () => [] }),\n // Site metadata that some blocks might reference.\n siteName: field.text({ translatable: true }),\n siteLogo: field.media(),\n siteFavicon: field.media(),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n workflowable(),\n // withOpLog ships from `@murumets-ee/blocks/server` — apps that\n // want the block-editor surface for site_layout should layer it\n // onto the entity at app-config time (matching how the playground\n // wires page-with-blocks). Keeping it out of the factory keeps the\n // content package's import direction one-way (content doesn't\n // depend on blocks).\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Singleton — apps usually seed the row; `create` admin gating\n // prevents accidental \"second row\" but stays open for the seed\n // path (which runs as the system role).\n create: publishRole,\n update: updateRole,\n delete: publishRole,\n },\n admin: {\n icon: 'layout',\n description:\n 'Shared header / footer / pre / post-content blocks rendered on every page that does not override them.',\n rootFields: ['slug', 'label'],\n },\n })\n}\n"],"mappings":"+LA+FA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,QACpC,EAAc,GAAS,aAAe,QACtC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cACN,KAAM,YACN,OAAQ,CAMN,KAAM,EAAM,KAAK,CACf,SAAU,GACV,QAAS,UACT,QAAS,EACX,CAAC,EACD,MAAO,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAGxC,aAAc,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EAC9C,aAAc,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EAE9C,oBAAqB,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EACrD,mBAAoB,EAAM,KAAK,CAAE,YAAe,CAAC,CAAE,CAAC,EAEpD,SAAU,EAAM,KAAK,CAAE,aAAc,EAAK,CAAC,EAC3C,SAAU,EAAM,MAAM,EACtB,YAAa,EAAM,MAAM,CAC3B,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,EAAa,CAOf,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAIN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,SACN,YACE,yGACF,WAAY,CAAC,OAAQ,OAAO,CAC9B,CACF,CAAC,CACH"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
var e=class extends Error{entityName;capability;constructor(e,t,n){super(n??`Entity "${e}" does not support ${t}. Add the corresponding behavior to defineEntity({ behaviors: [...] }).`),this.name=`EntityCapabilityError`,this.entityName=e,this.capability=t}},t=class extends Error{entityId;locale;constructor(e,t){super(t===`_`?`No pending draft to publish — this document is already up to date.`:`No pending draft to publish for locale "${t}" — it is already up to date.`),this.name=`DraftNotFoundError`,this.entityId=e,this.locale=t}},n=class extends Error{locale;allowed;constructor(e,t){super(`Invalid locale "${e}". Configured locales: [${t.join(`, `)}]. (The "_" sentinel is also accepted for default-locale storage.)`),this.name=`InvalidLocaleError`,this.locale=e,this.allowed=t}};export{e as n,n as r,t};
|
|
2
|
-
//# sourceMappingURL=errors-
|
|
2
|
+
//# sourceMappingURL=errors-bZAGPYhp.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors-
|
|
1
|
+
{"version":3,"file":"errors-bZAGPYhp.mjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Domain errors for ContentClient + LockService.\n */\n\n/**\n * Thrown when a caller invokes an entity-capability-specific method on an\n * entity that doesn't have the corresponding behavior — e.g. `saveDraft`\n * on a non-publishable entity, `setLocaleStatus` on an entity without\n * publishable+translatable, `restoreVersion` on a non-versionable entity.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 400 Bad Request (caller's fault) rather than 500 (server bug).\n */\nexport class EntityCapabilityError extends Error {\n readonly entityName: string\n readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n constructor(\n entityName: string,\n capability: EntityCapabilityError['capability'],\n message?: string,\n ) {\n super(\n message ??\n `Entity \"${entityName}\" does not support ${capability}. ` +\n `Add the corresponding behavior to defineEntity({ behaviors: [...] }).`,\n )\n this.name = 'EntityCapabilityError'\n this.entityName = entityName\n this.capability = capability\n }\n}\n\n/**\n * Thrown by `publishDraft` when there is no pending draft for the requested\n * entity+locale — publishing is a promote-the-draft operation, so \"nothing\n * to promote\" is a caller-state condition, not a server fault.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 409 Conflict with a human-readable message instead of a generic 500\n * (which is what users saw pre-PR-23 — F031 #1).\n */\nexport class DraftNotFoundError extends Error {\n readonly entityId: string\n readonly locale: string\n\n constructor(entityId: string, locale: string) {\n // NB: the `_` default-locale sentinel is an internal storage key — never\n // show it to users (PR 23 user-smoke find).\n super(\n locale === '_'\n ? 'No pending draft to publish — this document is already up to date.'\n : `No pending draft to publish for locale \"${locale}\" — it is already up to date.`,\n )\n this.name = 'DraftNotFoundError'\n this.entityId = entityId\n this.locale = locale\n }\n}\n\n/**\n * Thrown when a locale string passed at runtime is not configured in the\n * content plugin. The plugin validates `defaultLocale` at init time, but\n * the per-call `locale` parameter on every read/write is not naturally\n * checked — `validateLocale()` is the boundary check.\n *\n * Allows the `'_'` sentinel (used internally for default-locale rows in\n * versions/drafts/locale_status tables) so callers can pass it through.\n */\nexport class InvalidLocaleError extends Error {\n readonly locale: string\n readonly allowed: readonly string[]\n\n constructor(locale: string, allowed: readonly string[]) {\n super(\n `Invalid locale \"${locale}\". Configured locales: [${allowed.join(', ')}]. ` +\n `(The \"_\" sentinel is also accepted for default-locale storage.)`,\n )\n this.name = 'InvalidLocaleError'\n this.locale = locale\n this.allowed = allowed\n }\n}\n"],"mappings":"AAaA,IAAa,EAAb,cAA2C,KAAM,CAC/C,WACA,WAEA,YACE,EACA,EACA,EACA,CACA,MACE,GACE,WAAW,EAAW,qBAAqB,EAAW,wEAE1D,EACA,KAAK,KAAO,wBACZ,KAAK,WAAa,EAClB,KAAK,WAAa,CACpB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,SACA,OAEA,YAAY,EAAkB,EAAgB,CAG5C,MACE,IAAW,IACP,qEACA,2CAA2C,EAAO,8BACxD,EACA,KAAK,KAAO,qBACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,OACA,QAEA,YAAY,EAAgB,EAA4B,CACtD,MACE,mBAAmB,EAAO,0BAA0B,EAAQ,KAAK,IAAI,EAAE,mEAEzE,EACA,KAAK,KAAO,qBACZ,KAAK,OAAS,EACd,KAAK,QAAU,CACjB,CACF"}
|