@murumets-ee/content 0.107.0 → 0.108.1

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.
Files changed (46) hide show
  1. package/dist/admin.d.mts +23 -1
  2. package/dist/admin.d.mts.map +1 -1
  3. package/dist/admin.mjs +1 -1
  4. package/dist/chrome-provider.d.mts +1 -1
  5. package/dist/client.d.mts +2 -2
  6. package/dist/client.mjs +1 -1
  7. package/dist/context-D7eEItCY.mjs +2 -0
  8. package/dist/{context-DtHgyrgb.mjs.map → context-D7eEItCY.mjs.map} +1 -1
  9. package/dist/entities/index.d.mts +2 -2
  10. package/dist/{index-Bni-PiIz.d.mts → index-cT8Qk4gu.d.mts} +2 -2
  11. package/dist/{index-Bni-PiIz.d.mts.map → index-cT8Qk4gu.d.mts.map} +1 -1
  12. package/dist/index.d.mts +4 -4
  13. package/dist/lock-service.d.mts +1 -1
  14. package/dist/plugin-CjgqBSFy.mjs +2 -0
  15. package/dist/{plugin-CvPWz399.mjs.map → plugin-CjgqBSFy.mjs.map} +1 -1
  16. package/dist/plugin.d.mts +2 -2
  17. package/dist/plugin.mjs +1 -1
  18. package/dist/registry-CaPbY5lm.mjs +2 -0
  19. package/dist/registry-CaPbY5lm.mjs.map +1 -0
  20. package/dist/routes-siXmjKDA.mjs +2 -0
  21. package/dist/routes-siXmjKDA.mjs.map +1 -0
  22. package/dist/{routing-strategy-DVWB7qPp.d.mts → routing-strategy-CAG3tZP0.d.mts} +2 -2
  23. package/dist/{routing-strategy-DVWB7qPp.d.mts.map → routing-strategy-CAG3tZP0.d.mts.map} +1 -1
  24. package/dist/{site-chrome-BiV3zhw0.d.mts → site-chrome-DNCcePSR.d.mts} +2 -2
  25. package/dist/{site-chrome-BiV3zhw0.d.mts.map → site-chrome-DNCcePSR.d.mts.map} +1 -1
  26. package/dist/translation-provenance-B41lEDM-.mjs +2 -0
  27. package/dist/translation-provenance-B41lEDM-.mjs.map +1 -0
  28. package/dist/{translation-provenance--dHtcmfg.d.mts → translation-provenance-BZ0MlbFR.d.mts} +2 -2
  29. package/dist/{translation-provenance--dHtcmfg.d.mts.map → translation-provenance-BZ0MlbFR.d.mts.map} +1 -1
  30. package/dist/translation.d.mts +144 -2
  31. package/dist/translation.d.mts.map +1 -0
  32. package/dist/translation.mjs +2 -1
  33. package/dist/translation.mjs.map +1 -0
  34. package/dist/{types-wMTglM4j.d.mts → types-BOk9tg_9.d.mts} +2 -2
  35. package/dist/{types-wMTglM4j.d.mts.map → types-BOk9tg_9.d.mts.map} +1 -1
  36. package/dist/{versionable-D2JjselN.d.mts → versionable-CRvV87nZ.d.mts} +2 -2
  37. package/dist/{versionable-D2JjselN.d.mts.map → versionable-CRvV87nZ.d.mts.map} +1 -1
  38. package/dist/{versions-B0_9TtZ0.mjs → versions-w2ui-Vzb.mjs} +2 -2
  39. package/dist/{versions-B0_9TtZ0.mjs.map → versions-w2ui-Vzb.mjs.map} +1 -1
  40. package/package.json +10 -10
  41. package/dist/context-DtHgyrgb.mjs +0 -2
  42. package/dist/plugin-CvPWz399.mjs +0 -2
  43. package/dist/routes-D-NWP0hK.mjs +0 -2
  44. package/dist/routes-D-NWP0hK.mjs.map +0 -1
  45. package/dist/translation-provenance-eqhsGAyi.mjs +0 -2
  46. package/dist/translation-provenance-eqhsGAyi.mjs.map +0 -1
package/dist/plugin.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as LocaleDirection, l as RoutingStrategy, o as LocaleEntry, r as I18nConfig, t as ContentConfig } from "./types-wMTglM4j.mjs";
2
- import { a as ROUTING_STRATEGIES, i as Locale, n as prefixDefaultLocaleForStrategy, o as i18nSettings, r as Message, t as localePrefixForStrategy } from "./routing-strategy-DVWB7qPp.mjs";
1
+ import { a as LocaleDirection, l as RoutingStrategy, o as LocaleEntry, r as I18nConfig, t as ContentConfig } from "./types-BOk9tg_9.mjs";
2
+ import { a as ROUTING_STRATEGIES, i as Locale, n as prefixDefaultLocaleForStrategy, o as i18nSettings, r as Message, t as localePrefixForStrategy } from "./routing-strategy-CAG3tZP0.mjs";
3
3
 
4
4
  //#region src/content-config-store.d.ts
5
5
  /**
package/dist/plugin.mjs CHANGED
@@ -1 +1 @@
1
- import{n as e,r as t,t as n}from"./locale-entity-CoSq3C77.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i,r as a,t as o}from"./routing-strategy-D6B4ngT9.mjs";import{t as s}from"./plugin-CvPWz399.mjs";export{n as Locale,a as Message,e as ROUTING_STRATEGIES,s as content,r as getContentConfig,t as i18nSettings,o as localePrefixForStrategy,i as prefixDefaultLocaleForStrategy};
1
+ import{n as e,r as t,t as n}from"./locale-entity-CoSq3C77.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i,r as a,t as o}from"./routing-strategy-D6B4ngT9.mjs";import{t as s}from"./plugin-CjgqBSFy.mjs";export{n as Locale,a as Message,e as ROUTING_STRATEGIES,s as content,r as getContentConfig,t as i18nSettings,o as localePrefixForStrategy,i as prefixDefaultLocaleForStrategy};
@@ -0,0 +1,2 @@
1
+ const e=Symbol.for(`@murumets-ee/content:content-tables-registry`);function t(){let t=globalThis,n=t[e];return n||(n=new Map,t[e]=n),n}function n(e,n){let r=t(),i=r.get(e)??{};r.set(e,{...i,...n})}function r(e){return t().get(e)??{}}export{n,r as t};
2
+ //# sourceMappingURL=registry-CaPbY5lm.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry-CaPbY5lm.mjs","names":[],"sources":["../src/schema/registry.ts"],"sourcesContent":["/**\n * Per-entity content table registry.\n *\n * Maps an entity name to its content infrastructure tables — drafts,\n * versions, locale_status. Each entry stores the `DefinedTable` (from\n * `defineTable`), which exposes a typed `.makeClient(db)` for CRUD via\n * `TableClient` and a raw `.table` for legacy callers.\n *\n * The content plugin populates this registry at `init` time; ContentClient\n * reads from it lazily on first use. Tables that don't apply to an entity\n * (non-publishable entities have no drafts, etc.) are simply absent.\n */\n\nimport type { DraftsTable } from './drafts.js'\nimport type { LocaleStatusTable } from './locale-status.js'\nimport type { VersionsTable } from './versions.js'\n\n/**\n * The full set of optional content infra tables attached to an entity.\n * Every field is optional — entities without the relevant behavior don't\n * get the corresponding table.\n */\nexport interface ContentTables {\n /** `{entity}_drafts` — present when the entity has `publishable()`. */\n drafts?: DraftsTable\n /** `{entity}_versions` — present when the entity has `versionable()`. */\n versions?: VersionsTable\n /** `{entity}_locale_status` — present when the entity is publishable + has translatable fields. */\n localeStatus?: LocaleStatusTable\n}\n\n/**\n * The registry map lives on `globalThis` keyed by `Symbol.for(...)`, NOT as a\n * module-level binding — matching core's `PluginContributionsRegistry`\n * pattern. A module-level `Map` here is the exact Turbopack-dev footgun\n * CLAUDE.md documents (the createdBy/updatedBy bug class): a lazily-compiled\n * route chunk can resolve this module to a DIFFERENT instance than the boot\n * path that called `registerContentTables`, so `getContentTables()` returns\n * `{}` and callers silently take their no-table fallbacks. Observed in the\n * wild 2026-06-11 (F031 #1): the blocks op-commit route saw an empty registry,\n * `writeDraft` fell back to live-row writes, and Publish 500'd with \"No draft\n * found\" — flaky per-recompile, dev-only. `Symbol.for` is process-global, so\n * every duplicate module instance reads the same map.\n */\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/content:content-tables-registry')\n\ninterface GlobalWithRegistry {\n [REGISTRY_KEY]?: Map<string, ContentTables>\n}\n\nfunction registryMap(): Map<string, ContentTables> {\n const g = globalThis as GlobalWithRegistry\n let map = g[REGISTRY_KEY]\n if (!map) {\n map = new Map<string, ContentTables>()\n g[REGISTRY_KEY] = map\n }\n return map\n}\n\n/**\n * Register content tables for an entity. Called from `content().init()`.\n *\n * Subsequent calls for the same entity name MERGE the provided patch into\n * the existing entry. This lets the plugin add tables one at a time (drafts,\n * then versions, then locale_status) without the caller needing to build the\n * whole object up front.\n */\nexport function registerContentTables(entityName: string, patch: ContentTables): void {\n const tables = registryMap()\n const existing = tables.get(entityName) ?? {}\n tables.set(entityName, { ...existing, ...patch })\n}\n\n/**\n * Get the content tables for an entity. Returns an empty object (rather\n * than undefined) when the entity has no registered tables — that\n * simplifies the common ContentClient lookup idiom:\n *\n * const { drafts } = getContentTables(entity.name)\n * if (!drafts) return null\n */\nexport function getContentTables(entityName: string): ContentTables {\n return registryMap().get(entityName) ?? {}\n}\n\n/**\n * Clear the registry. **Test-only.** Throws in production.\n */\nexport function clearContentTables(): void {\n if (process.env.NODE_ENV === 'production') {\n throw new Error(\n 'clearContentTables() is not allowed in production — this is a test-only utility.',\n )\n }\n registryMap().clear()\n}\n"],"mappings":"AA4CA,MAAM,EAAe,OAAO,IAAI,8CAA8C,EAM9E,SAAS,GAA0C,CACjD,IAAM,EAAI,WACN,EAAM,EAAE,GAKZ,OAJK,IACH,EAAM,IAAI,IACV,EAAE,GAAgB,GAEb,CACT,CAUA,SAAgB,EAAsB,EAAoB,EAA4B,CACpF,IAAM,EAAS,EAAY,EACrB,EAAW,EAAO,IAAI,CAAU,GAAK,CAAC,EAC5C,EAAO,IAAI,EAAY,CAAE,GAAG,EAAU,GAAG,CAAM,CAAC,CAClD,CAUA,SAAgB,EAAiB,EAAmC,CAClE,OAAO,EAAY,CAAC,CAAC,IAAI,CAAU,GAAK,CAAC,CAC3C"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./content-config-store-8omQF5p5.mjs";import{t}from"./registry-CaPbY5lm.mjs";import{n,s as r}from"./schema-BYrYzsmO.mjs";import{d as i,n as a,s as o}from"./schema-LlK6EkOJ.mjs";import{getBlockTreeFieldName as s,getEntityTitleField as c,isPublishable as l,matchReservedPrefix as u}from"@murumets-ee/entity";import{combineAdminRoutes as d,defineAdminRoute as f,getContext as ee,requireRequestSite as te,requireSiteFor as ne,safeAudit as p}from"@murumets-ee/core";import{and as m,eq as h}from"drizzle-orm";import{z as g}from"zod";import{entityRefs as _}from"@murumets-ee/entity/refs";const v=`path:resolve`,re=[`admin`,`editor`];function y(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const b={kind:`none`};function x(e){return e===null||e.length===0?{error:`query param 'path' is required`}:e.length>2048?{error:`query param 'path' is too long`}:e.startsWith(`/`)?e.startsWith(`//`)?{error:`query param 'path' must not be protocol-relative`}:e.includes(`?`)||e.includes(`#`)?{error:`query param 'path' must not include a query or fragment`}:{path:e}:{error:`query param 'path' must start with '/'`}}function S(t,n){let r=e(),i=t??n??r.defaultLocale;return(ee()?.availableLocales??r.locales.map(e=>e.code)).includes(i)?{locale:i}:{error:`query param 'locale' is not a content locale`}}const ie=async(e,t)=>{let n=new URL(e.url).searchParams,r=x(n.get(`path`));if(`error`in r)return y({error:r.error},400);let i=S(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in i)return y({error:i.error},400);let{locale:a}=i,{buildResolveDeps:s}=await import(`./resolve-context-DklN53BH.mjs`).then(e=>e.u),c=s(t.app),l=async(e,n)=>{if(!t.checkPermission(e,`view`))return b;let r=await ae(t.app,e,n,a);return r===void 0?{kind:`document`,entityType:e,id:n}:{kind:`document`,entityType:e,id:n,form:r}},u=await o(r.path,a,c);if(u.kind===`document`)return y(await l(u.entityType,u.id));if(u.kind===`none`)return y(b);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return y({kind:`redirect`,to:u.to});let d=await o(u.to,a,c);return y(d.kind===`document`?await l(d.entityType,d.id):b)};async function ae(n,r,i,a){let o=n.entities?.get(r);if(!(o===void 0||s(o)!==void 0))try{let r=n.getClient(o),s=await r.findById(i,{locale:a});if(s===null)return{title:null,published:null};let u=s[c(o)],d=typeof u==`string`&&u.trim().length>0?u:null;if(!l(o))return{title:d,published:!0};let f=s.status===`published`;if(a!==e().defaultLocale){let{localeStatus:e}=t(o.name);if(e!==void 0){let t=(await e.makeClient(r.getDb(),await ne(o)).findMany({where:{entityId:i,locale:a},limit:1}))[0];t!==void 0&&(f=t.status===`published`)}}return{title:d,published:f}}catch(e){return n.logger?.debug({err:e,entityType:r,id:i},`paths/resolve: could not read a form document`),{title:null,published:null}}}function oe(){return d([f({prefix:`paths`,path:`resolve`,method:`GET`,permission:v,defaultRoles:re,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,tokens:`accept`,handler:ie})])}var C=class extends Error{fromPath;prefix;constructor(e,t){super(`Redirect source "${e}" falls under the reserved prefix "${t}" and cannot be used — that path range is owned by the framework.`),this.name=`RedirectReservedPrefixError`,this.fromPath=e,this.prefix=t}},w=class extends Error{fromPath;locale;conflictingLocale;constructor(e,t,n){super(`Redirect source "${e}" already serves content in locale "${n}" (it is a live canonical URL). Choose a different source path, or unpublish/rename the content first.`),this.name=`RedirectServesContentError`,this.fromPath=e,this.locale=t,this.conflictingLocale=n}},T=class extends Error{entity;constructor(e){super(`Redirect target references entity type "${e}", which is not a registered entity. Check the entity name.`),this.name=`RedirectUnknownEntityError`,this.entity=e}},E=class extends Error{entity;id;constructor(e,t){super(`Redirect target ${e} "${t}" does not exist. Redirect to a document that exists, or use a URL/path target instead.`),this.name=`RedirectTargetNotFoundError`,this.entity=e,this.id=t}},D=class extends Error{fromPath;locale;constructor(e,t){super(`Redirect source "${e}"${t===null?``:` (locale "${t}")`} resolves back to itself — a redirect cannot point at its own source path. Choose a different destination.`),this.name=`RedirectSelfReferenceError`,this.fromPath=e,this.locale=t}};const O=`toolkit_redirects`;async function k(e,t,n){n.kind===`entity`&&await e.insert(_).values({sourceEntity:O,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function A(e,t){await e.delete(_).where(m(h(_.sourceEntity,O),h(_.sourceId,t)))}const j=g.discriminatedUnion(`kind`,[g.object({kind:g.literal(`entity`),entity:g.string().min(1),id:g.string().uuid()}),g.object({kind:g.literal(`url`),url:g.string().max(2048).url()}),g.object({kind:g.literal(`path`),path:g.string().min(1).max(2048).startsWith(`/`)})]);function M(e){return i(e)}function N(e){let t=M(e.fromPath),n=u(t,e.reservedPathPrefixes);if(n!==void 0)throw new C(t,n)}async function P(e){let t=M(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new w(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new w(t,null,n.locale)}async function se(e){if(e.target.kind!==`path`)return e.target;let t=M(e.target.path),n=e.excludeId===void 0?{}:{id:{ne:e.excludeId}};if(e.locale!==null){let r=await e.redirectsClient.findOne({fromPath:t,locale:e.locale,...n});if(r)return r.target}let r=await e.redirectsClient.findOne({fromPath:t,locale:null,...n});return r?r.target:{kind:`path`,path:t}}async function ce(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new T(t);if(await r.findById(n)==null)throw new E(t,n)}function F(){return te(`toolkit_redirects`)}const I=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,le=/^[a-zA-Z0-9:_.-]{1,100}$/,L=`redirect:manage`,R=[`admin`];function z(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function B(e,t){return z({error:e},t)}async function V(e,t){let n;try{n=await e.json()}catch{return{response:B(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:B(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function H(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function U(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function W(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function G(){return(await W()).db.readWrite}async function K(){let e=await W();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function q(e){return e}const J=g.union([g.literal(301),g.literal(302)]),Y=g.string().min(1).max(2048).startsWith(`/`),ue=g.object({locale:g.string().min(1).nullable(),fromPath:Y,target:j,status:J.default(301)}),de=g.object({locale:g.string().min(1).nullable().optional(),fromPath:Y.optional(),target:j.optional(),status:J.optional()});function X(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:B(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Z(e){if(e instanceof C)return z({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof w)return z({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof D)return z({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof T)return z({error:e.message,entity:e.entity},400);if(e instanceof E)return z({error:e.message,entity:e.entity,id:e.id},400);if(r(e))return z({error:`A redirect already exists for this locale + source path.`},409);throw e}async function Q(t){let r=e(),i=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=M(t.fromPath);N({fromPath:s,reservedPathPrefixes:i}),await ce({target:t.target,resolveClient:t.resolveClient});let c=await G(),l=await F();return c.transaction(async e=>{let r=q(e),i=a.makeClient(r,l),c=n.makeClient(r,l);await P({fromPath:s,locale:t.locale,configuredLocales:o,pathsClient:c});let u=await se({target:t.target,locale:t.locale,redirectsClient:i,...t.id===null?{}:{excludeId:t.id}});if(u.kind===`path`&&M(u.path)===s)throw new D(s,t.locale);if(t.id===null){let e=await i.insert({locale:t.locale,fromPath:s,target:u,status:t.status,createdBy:t.userId});return await k(r,e.id,u),e}let d=await i.update({id:t.id},{locale:t.locale,fromPath:s,target:u,status:t.status,updatedAt:new Date});return d?(await A(r,t.id),await k(r,t.id,u),d):null})}const $=async e=>{let t=new URL(e.url),n=t.searchParams.get(`locale`),r=Math.min(Math.max(H(t.searchParams.get(`limit`),50),1),200),i=Math.min(H(t.searchParams.get(`offset`),0),1e5),o;if(n!==null&&n!==``){let e=X(n);if(e)return e;o=n}let s=a.makeClient(await G(),await F());return z({items:o===void 0?await s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:i}):await s.findMany({where:{locale:o},orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:i}),total:o===void 0?await s.count():await s.count({locale:o})})},fe=async(e,t)=>{let n=await V(e,ue);if(`response`in n)return n.response;let r=n.data,i=X(r.locale);if(i)return i;try{let e=await K(),n=await Q({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(p(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...U(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),z({item:n},201)):B(`Failed to create redirect`,500)}catch(e){return Z(e)}},pe=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return B(`Invalid id`,400);let r=await V(e,de);if(`response`in r)return r.response;let i=r.data,o=await a.makeClient(await G(),await F()).findOne({id:n});if(!o)return B(`Not found`,404);let s=i.locale===void 0?o.locale:i.locale,c=i.fromPath===void 0?o.fromPath:i.fromPath,l=i.target===void 0?o.target:i.target,u=i.status===void 0?o.status:i.status,d=X(s);if(d)return d;try{let e=await K(),r=await Q({id:n,locale:s,fromPath:c,target:l,status:u,userId:t.user.id,resolveClient:e});return r?(p(t,{action:`redirect.updated`,entityType:`redirect`,entityId:r.id,...U(t.user),metadata:{locale:r.locale,fromPath:r.fromPath,targetKind:r.target.kind}}),z({item:r})):B(`Not found`,404)}catch(e){return Z(e)}},me=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return B(`Invalid id`,400);let r=await G(),i=await F(),o=await r.transaction(async e=>{let t=q(e),r=await a.makeClient(t,i).delete({id:n});return r&&await A(t,n),r});return o?(p(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:n,...U(t.user),metadata:{locale:o.locale,fromPath:o.fromPath}}),z({ok:!0})):B(`Not found`,404)},he=async e=>{let t=new URL(e.url),r=t.searchParams.get(`entityType`),i=t.searchParams.get(`entityId`);if(!r||!le.test(r))return B(`entityType is required`,400);if(!i||!I.test(i))return B(`entityId is required and must be a UUID`,400);let a=t.searchParams.get(`locale`),o;if(a!==null&&a!==``){let e=X(a);if(e)return e;o=a}return z({items:await n.makeClient(await G(),await F()).findMany({where:{entityType:r,entityId:i,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function ge(){return d([f({prefix:`redirects`,path:``,method:`GET`,permission:L,defaultRoles:R,description:`List manual URL redirects (paginated, optional locale filter).`,tokens:`accept`,handler:$}),f({prefix:`redirects`,path:`history`,method:`GET`,permission:L,defaultRoles:R,description:`Read-only automatic-redirect (rename) history for one document.`,tokens:`accept`,handler:he}),f({prefix:`redirects`,path:``,method:`POST`,permission:L,defaultRoles:R,description:`Create a manual URL redirect.`,tokens:`accept`,handler:fe}),f({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:L,defaultRoles:R,description:`Update a manual URL redirect by id.`,tokens:`accept`,handler:pe}),f({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:L,defaultRoles:R,description:`Delete a manual URL redirect by id.`,tokens:`accept`,confirm:!0,handler:me})])}export{v as n,oe as r,ge as t};
2
+ //# sourceMappingURL=routes-siXmjKDA.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes-siXmjKDA.mjs","names":["json"],"sources":["../src/paths/routes.ts","../src/redirects/errors.ts","../src/redirects/refs.ts","../src/redirects/target.ts","../src/redirects/validation.ts","../src/redirects/routes.ts"],"sourcesContent":["/**\n * Path-resolution admin route — \"which document owns this URL?\".\n *\n * Mounted under prefix `paths`. The URL surface:\n *\n * GET /api/admin/paths/resolve ?path=/foo/bar&locale=en\n *\n * **Why this exists.** The block editor's preview canvas is a `srcDoc` iframe,\n * so a rendered link's site-absolute `href` resolves against the ADMIN origin —\n * clicking one navigated the CANVAS and could render the whole admin editor\n * inside itself (#775). `@murumets-ee/blocks` now contains every in-canvas link\n * click and hands it to its host; the host needs exactly one server capability\n * to make that click useful: turn a site path into the document that serves it,\n * so it can open THAT document's editor.\n *\n * **Draft resolution, deliberately.** Uses `resolveDraftPath` (not\n * `resolvePath`): an editor following a link to an unpublished sibling page\n * must land on its editor, and the caller is an authenticated admin who can\n * already read the row through the entity API. This is exactly the\n * \"admin-only surface\" `resolveDraftPath`'s docblock reserves — the FIRST HTTP\n * route wired to it. It must never be reachable from `content-api` (public,\n * published-only).\n *\n * **Authorization — two gates.**\n * 1. Route gate (`defineAdminRoute`): `path:resolve`, `defaultRoles:\n * ['admin', 'editor']`. Viewers are excluded; the capability only exists\n * to navigate BETWEEN editors.\n * 2. Per-document gate: the resolved `entityType` must also pass\n * `ctx.checkPermission(entityType, 'view')`. A caller with the route\n * permission but no grant on that entity gets `{ kind: 'none' }` — the\n * SAME answer as a path that owns nothing, so the response never\n * discloses that a document exists there (no-leak, matching\n * `menus`' anchors route and content-api's 404-not-403 rule).\n *\n * **Bounded work.** One resolve is a handful of indexed single-row reads. A\n * `redirect`-kind result is followed exactly ONCE more (the index stores\n * collapsed targets, and `resolveInternal` already caps its own chain at\n * `MAX_REDIRECT_HOPS`) — so this handler's cost is constant, never a chain\n * walk driven by caller input.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n getContext,\n requireSiteFor,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport { getBlockTreeFieldName, getEntityTitleField, isPublishable } from '@murumets-ee/entity'\nimport { getContentConfig } from '../content-config-store.js'\nimport { getContentTables } from '../schema/registry.js'\n// `./resolve.js` is a LEAF (every dependency injected, no `@murumets-ee/core`\n// graph) so it imports statically. `./resolve-context.js` is NOT — it holds the\n// app-binding imports, and its own header reserves it for request-time surfaces\n// and forbids the `lumi` CLI command graph (F027 guardrail). This module is\n// reached from `plugin.ts`'s STATIC graph, which the CLI loads through\n// `lumi.config.ts`, so the binding is imported at REQUEST time inside the\n// handler instead — same deferral `redirects/routes.ts` uses for `getApp`.\nimport { resolveDraftPath } from './resolve.js'\n\n/** Route permission + the roles that hold it by default. */\nexport const PATH_RESOLVE_PERMISSION = 'path:resolve'\nexport const PATH_RESOLVE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Cap on the `path` query param. `toolkit_paths.path` is `text` (no DB cap), so\n * the bound is ours: generously above any real URL, low enough that the param\n * can't be used to push megabytes through the query planner.\n */\nconst MAX_PATH_LENGTH = 2048\n\n/** The answer shape the caller (admin-ui's editor canvas) consumes. */\nexport type ResolvePathResponse =\n /**\n * The path is served by this document.\n *\n * `form` is present exactly when the document's TYPE has no canvas (no\n * `field.blockTree()` — an article with a rich-text body): the editor has\n * nothing to draw it with, so preview shows the site's own page instead and\n * the bar needs a name for it and a way to its form (editor-refurb D017).\n * Absent for a type with a canvas, whose answer is unchanged.\n */\n | {\n readonly kind: 'document'\n readonly entityType: string\n readonly id: string\n readonly form?: ResolvedFormDocument\n }\n /** The path redirects OFF-SITE (a `url`-kind manual redirect). */\n | { readonly kind: 'redirect'; readonly to: string }\n /** Nothing the caller may see owns this path. */\n | { readonly kind: 'none' }\n\n/** What the editor may say about a document it cannot draw. */\nexport interface ResolvedFormDocument {\n /** The row's title in the asked locale; `null` when it has none or the read failed. */\n readonly title: string | null\n /**\n * Whether the SITE shows it: `false` for a draft of a publishable type (the\n * front serves published content only, so there is nothing to frame), `true`\n * otherwise. `null` when the row could not be read.\n */\n readonly published: boolean | null\n}\n\nfunction json(data: ResolvePathResponse | { error: string }, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nconst NONE: ResolvePathResponse = { kind: 'none' }\n\n/**\n * Boundary validation for the `path` param. Rejects (rather than normalizes)\n * anything that isn't a bare locale-relative path: a protocol-relative\n * `//host` or a `?query` / `#fragment` tail means the CALLER built the request\n * wrong, and answering it would silently resolve something other than what it\n * asked about.\n */\nfunction validatePath(raw: string | null): { path: string } | { error: string } {\n if (raw === null || raw.length === 0) return { error: \"query param 'path' is required\" }\n if (raw.length > MAX_PATH_LENGTH) return { error: \"query param 'path' is too long\" }\n if (!raw.startsWith('/')) return { error: \"query param 'path' must start with '/'\" }\n if (raw.startsWith('//')) return { error: \"query param 'path' must not be protocol-relative\" }\n if (raw.includes('?') || raw.includes('#')) {\n return { error: \"query param 'path' must not include a query or fragment\" }\n }\n return { path: raw }\n}\n\n/**\n * Whitelist the locale (CLAUDE.md \"whitelist, don't blacklist\"), falling back\n * to the request's own content locale and then the configured default so the\n * common call can omit it.\n *\n * Checks the **request-scoped effective set** — `content()` shipped defaults ⊕\n * DB `locale` override rows, resolved once per request (SD006) — with the static\n * set as fallback outside a request context. Using only the static list was a\n * real bug, not just an inconsistency: the in-editor locale switcher is driven\n * from `effectiveLocales`, so a locale enabled at RUNTIME is a legal editor\n * locale, and it would have 400'd here — making EVERY canvas link click in that\n * locale report \"no CMS page serves …\".\n *\n * **Why this doesn't just call `locale-utils.validateLocale`**, which implements\n * exactly this predicate: that module imports `getContentConfig` from\n * `./plugin.js` rather than from the leaf store, deliberately and with a\n * documented reason (see `content-config-store.ts`'s header — its consumers'\n * unit tests mock `plugin.js`, and repointing it ripples through ~7 mocks).\n * Since `plugin.ts` imports THIS module, calling it would close a real import\n * cycle (`locale-utils -> plugin -> paths/routes -> locale-utils`), which\n * `verify:cycles` rejects. So the same two-line read is done here against the\n * same sources of truth, rather than growing this PR into that refactor.\n */\nfunction resolveRequestLocale(\n raw: string | null,\n fallback: string | undefined,\n): { locale: string } | { error: string } {\n const config = getContentConfig()\n const candidate = raw ?? fallback ?? config.defaultLocale\n const allowed = getContext()?.availableLocales ?? config.locales.map((l) => l.code)\n if (!allowed.includes(candidate)) {\n // Deliberately does NOT echo the allowed set — no reason to enumerate the\n // deployment's configured locales to the caller.\n return { error: \"query param 'locale' is not a content locale\" }\n }\n return { locale: candidate }\n}\n\n/**\n * `GET /api/admin/paths/resolve?path=&locale=` — answer \"which document serves\n * this URL?\".\n *\n * Validates both params at the boundary, resolves through the draft resolver,\n * and projects the result through the per-document `view` gate so an\n * unviewable document is indistinguishable from an unowned path. See the module\n * header for the authorization model and why the deps builder is imported\n * lazily.\n */\nconst handleResolve: AdminRouteHandler = async (req, ctx) => {\n const params = new URL(req.url).searchParams\n\n const pathResult = validatePath(params.get('path'))\n if ('error' in pathResult) return json({ error: pathResult.error }, 400)\n const localeResult = resolveRequestLocale(params.get('locale'), ctx.locale ?? ctx.defaultLocale)\n if ('error' in localeResult) return json({ error: localeResult.error }, 400)\n const { locale } = localeResult\n\n const { buildResolveDeps } = await import('./resolve-context.js')\n const deps = buildResolveDeps(ctx.app)\n\n // Gate 2 (see the module header) — a document the caller can't view answers\n // exactly like an unowned path.\n const project = async (entityType: string, id: string): Promise<ResolvePathResponse> => {\n if (!ctx.checkPermission(entityType, 'view')) return NONE\n const form = await describeFormDocument(ctx.app, entityType, id, locale)\n return form === undefined\n ? { kind: 'document', entityType, id }\n : { kind: 'document', entityType, id, form }\n }\n\n const result = await resolveDraftPath(pathResult.path, locale, deps)\n if (result.kind === 'document') return json(await project(result.entityType, result.id))\n if (result.kind === 'none') return json(NONE)\n\n // `redirect`: an off-site target is handed back for the host to open; a\n // same-site one is followed ONE hop so the caller lands on the document\n // instead of round-tripping. No loop — exactly one extra resolve. A\n // protocol-relative `//host` target counts as off-site, not as a path (target\n // validation shouldn't produce one, but resolving it AS a path would silently\n // answer about something else entirely).\n const sameSite = result.to.startsWith('/') && !result.to.startsWith('//')\n if (!sameSite) return json({ kind: 'redirect', to: result.to })\n const hop = await resolveDraftPath(result.to, locale, deps)\n return json(hop.kind === 'document' ? await project(hop.entityType, hop.id) : NONE)\n}\n\n/**\n * For a document whose type has NO canvas: its title and whether the site\n * shows it. `undefined` for a type with a canvas (or one this app does not\n * know) — the answer then stays exactly what it was before D017.\n *\n * One extra single-row read, only on this branch, through the caller's own\n * client: `findById` re-asserts `<entity>:view` and the caller's scope, so the\n * title of a row the caller may not read is never returned — it degrades to\n * `null`, like any failed read, and the editor falls back to the address.\n */\nasync function describeFormDocument(\n app: ToolkitApp,\n entityType: string,\n id: string,\n locale: string,\n): Promise<ResolvedFormDocument | undefined> {\n const entity = app.entities?.get(entityType)\n if (entity === undefined || getBlockTreeFieldName(entity) !== undefined) return undefined\n try {\n const admin = app.getClient(entity)\n const row = (await admin.findById(id, { locale })) as Record<string, unknown> | null\n if (row === null) return { title: null, published: null }\n const rawTitle = row[getEntityTitleField(entity)]\n const title = typeof rawTitle === 'string' && rawTitle.trim().length > 0 ? rawTitle : null\n if (!isPublishable(entity)) return { title, published: true }\n // Whether the SITE shows it in THIS locale: a non-default locale has its\n // own publish state (the public read is `COALESCE(locale_status, base)`),\n // read the way the edit page reads it. No row for the locale → the base.\n let published = row.status === 'published'\n if (locale !== getContentConfig().defaultLocale) {\n // The same table and query `getLocaleStatusBatch` reads, through the\n // schema registry directly: going through `ContentClient` would import\n // this package's plugin graph back into the module the plugin imports.\n const { localeStatus } = getContentTables(entity.name)\n if (localeStatus !== undefined) {\n const rows = await localeStatus\n .makeClient(admin.getDb(), await requireSiteFor(entity))\n .findMany({ where: { entityId: id, locale }, limit: 1 })\n const own = rows[0]\n if (own !== undefined) published = own.status === 'published'\n }\n }\n return { title, published }\n } catch (err: unknown) {\n app.logger?.debug({ err, entityType, id }, 'paths/resolve: could not read a form document')\n return { title: null, published: null }\n }\n}\n\n/**\n * Build the path-resolution admin routes. One entry: `GET /paths/resolve`.\n */\nexport function pathRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'paths',\n path: 'resolve',\n method: 'GET',\n permission: PATH_RESOLVE_PERMISSION,\n defaultRoles: PATH_RESOLVE_DEFAULT_ROLES,\n description:\n 'Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).',\n // A read, and it answers only for a document the caller may `view`.\n tokens: 'accept',\n handler: handleResolve,\n }),\n ])\n}\n","/**\n * Domain errors thrown by the manual-redirect save-time validation\n * (`./validation.ts`, plan/routing PR05, phase `04-path-lifecycle-and-redirects.md`\n * §3). Distinct classes so Task 2's admin API handler can map each to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n *\n * Style mirrors `../paths/errors.ts` exactly (`extends Error`, named readonly\n * fields, an actionable message, `this.name = 'ClassName'`). A redirect-specific\n * reserved-prefix error is defined here rather than reusing `paths`'\n * `PathReservedPrefixError`, whose constructor requires an `entityName` — a\n * redirect has no owning entity, so forcing that field would be a lie.\n */\n\n/**\n * A manual redirect's `from_path` falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * redirect shadow a route the framework owns — the same hazard the path-index\n * guard rejects for entity canonical URLs — so the write is aborted before commit.\n */\nexport class RedirectReservedPrefixError extends Error {\n readonly fromPath: string\n readonly prefix: string\n\n constructor(fromPath: string, prefix: string) {\n super(\n `Redirect source \"${fromPath}\" falls under the reserved prefix \"${prefix}\" and cannot be ` +\n `used — that path range is owned by the framework.`,\n )\n this.name = 'RedirectReservedPrefixError'\n this.fromPath = fromPath\n this.prefix = prefix\n }\n}\n\n/**\n * A manual redirect's `from_path` already matches a live canonical path — that URL\n * serves content, so redirecting away from it would hide the content (phase 04 §3,\n * \"that URL serves content\"). The write is aborted before commit.\n *\n * `locale` is the redirect's own locale (`null` = a catch-all redirect, which was\n * checked against every configured locale); `conflictingLocale` names the specific\n * locale whose canonical path collided, so the message can be precise even for a\n * catch-all redirect.\n */\nexport class RedirectServesContentError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n readonly conflictingLocale: string\n\n constructor(fromPath: string, locale: string | null, conflictingLocale: string) {\n super(\n `Redirect source \"${fromPath}\" already serves content in locale \"${conflictingLocale}\" ` +\n `(it is a live canonical URL). Choose a different source path, or unpublish/rename the ` +\n `content first.`,\n )\n this.name = 'RedirectServesContentError'\n this.fromPath = fromPath\n this.locale = locale\n this.conflictingLocale = conflictingLocale\n }\n}\n\n/**\n * An `entity`-kind redirect target names an entity type that isn't registered in\n * the app. A redirect to an unknown entity type can never resolve, so it's\n * rejected at save (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectUnknownEntityError extends Error {\n readonly entity: string\n\n constructor(entity: string) {\n super(\n `Redirect target references entity type \"${entity}\", which is not a registered entity. ` +\n `Check the entity name.`,\n )\n this.name = 'RedirectUnknownEntityError'\n this.entity = entity\n }\n}\n\n/**\n * An `entity`-kind redirect target names a real entity type but a row id that\n * doesn't exist. A redirect to a nonexistent document can never resolve to a\n * useful URL, so it's rejected at save rather than discovered as a dead link at\n * request time (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectTargetNotFoundError extends Error {\n readonly entity: string\n readonly id: string\n\n constructor(entity: string, id: string) {\n super(\n `Redirect target ${entity} \"${id}\" does not exist. Redirect to a document that exists, or ` +\n `use a URL/path target instead.`,\n )\n this.name = 'RedirectTargetNotFoundError'\n this.entity = entity\n this.id = id\n }\n}\n\n/**\n * A manual redirect's collapsed target points straight back at its OWN\n * `from_path` (in the same locale being saved) — a self-loop / no-op that could\n * never resolve to content. Rejected by Task 2's CRUD route BEFORE the row is\n * persisted, rather than stored as a degenerate row a request-time resolver\n * would have to special-case.\n *\n * Distinct from the collapse rule (`./validation.ts` `collapsePathTarget`, which\n * dedupes redirect CHAINS down to one hop): this guards the case where that\n * collapse resolves the destination back to the source path of the row itself.\n * `locale` is the redirect's own locale (`null` = catch-all).\n */\nexport class RedirectSelfReferenceError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n\n constructor(fromPath: string, locale: string | null) {\n super(\n `Redirect source \"${fromPath}\"${locale === null ? '' : ` (locale \"${locale}\")`} resolves ` +\n `back to itself — a redirect cannot point at its own source path. Choose a different ` +\n `destination.`,\n )\n this.name = 'RedirectSelfReferenceError'\n this.fromPath = fromPath\n this.locale = locale\n }\n}\n","/**\n * `entity_refs` bookkeeping for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3; decision F020 point 2).\n *\n * When a redirect targets an ENTITY, we register a row in the universal\n * `entity_refs` table so the broken-link dashboard can surface \"this redirect\n * points at a deleted document\". No public write helper exists for this:\n * `AdminClient.syncRefs` is private and entity-only (it needs a field map to\n * `extractRefs`), and `toolkit_redirects` is a `defineTable` infra row, not an\n * AdminClient entity. So we hand-roll insert/delete against the exported\n * `entityRefs` table, mirroring `syncRefs`'s shape (F020 point 2).\n *\n * The ref uses `relationKind: 'link'` — a SOFT reference. Only `'structured'`\n * refs block deleting the target entity (AdminClient's delete-protection filters\n * on that kind); a redirect must NEVER block deleting the document it points at\n * (phase 04 §3's Delete row: the manual redirect merely *surfaces in the\n * broken-link view* via `entity_refs`, it doesn't hard-depend). `'link'` is\n * exactly that: tracked, never blocking.\n *\n * `sourceEntity: 'toolkit_redirects'` is safe as a free-form marker —\n * `entity_refs.sourceEntity` / `targetEntity` are plain varchar with no FK to a\n * registered-entity list.\n *\n * Both functions take `exec: PostgresJsDatabase` (the same parameter type\n * `AdminClient.syncRefs` takes), so Task 2's route can call them inside the SAME\n * transaction as the `toolkit_redirects` write itself — keeping the row write and\n * its ref bookkeeping atomic.\n */\n\nimport { entityRefs, type RelationKind } from '@murumets-ee/entity/refs'\nimport { and, eq } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { RedirectTarget } from './target.js'\n\n/** The `entity_refs.source_entity` marker for every manual-redirect ref row. */\nconst SOURCE_ENTITY = 'toolkit_redirects'\n/** The `entity_refs.source_field` marker — the `target` column of the redirect. */\nconst SOURCE_FIELD = 'target'\n/** Soft reference: tracked for the broken-link dashboard, never delete-blocking. */\nconst RELATION_KIND: RelationKind = 'link'\n\n/**\n * Register the `entity_refs` row for a redirect whose target is an entity.\n * No-op for `url` / `path` targets (nothing to track). Idempotent via\n * `.onConflictDoNothing()` (the `uq_entity_refs` unique covers\n * source+field+target), matching `AdminClient.syncRefs`'s own pattern — so a\n * re-run (e.g. an update that re-writes the same entity target) doesn't error.\n *\n * Call INSIDE the redirect-write transaction, AFTER {@link clearRedirectRefs} on\n * update (delete-then-insert; see that function's note).\n */\nexport async function writeRedirectRef(\n exec: PostgresJsDatabase,\n redirectId: string,\n target: RedirectTarget,\n): Promise<void> {\n if (target.kind !== 'entity') return\n await exec\n .insert(entityRefs)\n .values({\n sourceEntity: SOURCE_ENTITY,\n sourceId: redirectId,\n sourceField: SOURCE_FIELD,\n targetEntity: target.entity,\n targetId: target.id,\n relationKind: RELATION_KIND,\n })\n .onConflictDoNothing()\n}\n\n/**\n * Remove ALL `entity_refs` rows for a redirect. Called:\n * - before {@link writeRedirectRef} on UPDATE — delete-then-insert is simpler and\n * safer than diffing the old vs new target, and this is not a hot path; and\n * - on DELETE of the redirect row, so no dangling ref survives.\n *\n * Call INSIDE the redirect-write transaction so the ref cleanup commits (or rolls\n * back) atomically with the row change.\n */\nexport async function clearRedirectRefs(\n exec: PostgresJsDatabase,\n redirectId: string,\n): Promise<void> {\n await exec\n .delete(entityRefs)\n .where(and(eq(entityRefs.sourceEntity, SOURCE_ENTITY), eq(entityRefs.sourceId, redirectId)))\n}\n","/**\n * `RedirectTarget` — the discriminated union stored in `toolkit_redirects.target`\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3; decision\n * F020 point 1).\n *\n * Phase 04 §3 sketches this as \"LinkValue-shaped\", but it is NOT literally blocks'\n * D008 `LinkValue` (`packages/blocks/src/core/types.ts`), which is\n * `entity | media | url | email` — it has NO `path` arm and carries `media`/`email`\n * arms a redirect never needs. `asLinkValue` / `resolveLinkHref` / the picker UI all\n * switch on that union exhaustively with no `default`, so bolting a `path` arm onto\n * the real type would ripple into blocks' richtext/picker surface for zero benefit\n * here. So this is a redirect-local union matching the phase doc's actual three\n * kinds (F020 point 1) — \"LinkValue-shaped\" read as \"same tagged-union spirit\",\n * not a literal type import.\n *\n * The three kinds:\n * - `entity` — points at a document. Resolves to that document's *current*\n * canonical path AT REQUEST TIME (never stale, per phase 04 §3 / PR06's\n * `resolvePath`), and registers an `entity_refs` row for broken-link dashboard\n * coverage (see `./refs.ts`).\n * - `url` — an absolute external URL. Admin-authored only (the table is never\n * writable from a public surface), so no open-redirect vector.\n * - `path` — a locale-relative internal path. Collapsed at save time if it\n * itself points at another manual redirect (see `./validation.ts`), so a stored\n * `path` target always points at content, never at another redirect row.\n */\n\nimport { z } from 'zod'\n\n/** The stored shape of `toolkit_redirects.target`. */\nexport type RedirectTarget =\n | { kind: 'entity'; entity: string; id: string }\n | { kind: 'url'; url: string }\n | { kind: 'path'; path: string }\n\n/**\n * API-boundary validation for a {@link RedirectTarget} (CLAUDE.md \"validate at the\n * boundary\"). Task 2's admin CRUD routes parse the request body's `target` with\n * this before any write. Discriminated on `kind` so a malformed arm reports the\n * precise field, and unknown `kind`s are rejected rather than silently stored.\n */\nexport const redirectTargetSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n /** Registered entity type name (existence checked in `./validation.ts`). */\n entity: z.string().min(1),\n /** Entity row id — always a UUID (entity ids are UUIDs; only user ids are varchar). */\n id: z.string().uuid(),\n }),\n z.object({\n kind: z.literal('url'),\n /** Absolute external URL. */\n url: z.string().max(2048).url(),\n }),\n z.object({\n kind: z.literal('path'),\n /** Locale-relative internal path — leading '/', same convention as `toolkit_paths.path`. */\n path: z.string().min(1).max(2048).startsWith('/'),\n }),\n])\n","/**\n * Save-time validation for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). Each rule is an exported function\n * Task 2's admin CRUD route calls BEFORE writing a `toolkit_redirects` row.\n *\n * The recommended route order (Task 2 owns the actual wiring):\n * 1. normalize `fromPath` + a `path`-kind target's `path` via {@link normalizeRedirectPath}\n * 2. {@link assertFromPathNotReserved} — reserved-prefix rejection (security)\n * 3. {@link assertFromPathNotServingContent} — \"that URL serves content\" rejection\n * 4. {@link assertEntityTargetValid} — entity target type + id existence\n * 5. {@link collapsePathTarget} — collapse a redirect-chain to one hop\n * 6. write the row (+ `entity_refs` via `./refs.ts`) inside one transaction\n *\n * ## Dependency injection (leaf-friendly, testable)\n *\n * The paths / redirects readers are injected as NARROW structural interfaces\n * ({@link PathsCanonicalReader}, {@link RedirectsCollapseReader}) rather than the\n * full `TableClient`. The real `ToolkitPathsClient` / `ToolkitRedirectsClient`\n * satisfy them (proven at compile time by the integration test, which passes the\n * real clients straight in), so Task 2 injects the real client while unit tests\n * inject a trivial stub — no `as unknown as` casts on either side. The\n * entity-existence check is injected the same way, as {@link\n * RedirectEntityClientResolver}, so this module never imports `@murumets-ee/core`.\n */\n\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport { normalizePath } from '../paths/generate-path.js'\nimport {\n RedirectReservedPrefixError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport type { RedirectTarget } from './target.js'\n\n/**\n * Normalize a redirect path the SAME way the path index normalizes\n * `toolkit_paths.path` (collapse duplicate slashes + strip a trailing slash), so a\n * redirect's `from_path` and a `path`-kind target compare byte-identically against\n * the stored canonical/redirect paths. Task 2 should apply this to the stored\n * value too, so what is written matches what was validated. Re-exported thin\n * wrapper over the path module's `normalizePath` (DRY — do not reimplement).\n */\nexport function normalizeRedirectPath(path: string): string {\n return normalizePath(path)\n}\n\n/**\n * The read surface {@link assertFromPathNotServingContent} needs from the paths\n * index. The real `ToolkitPathsClient` satisfies this (its `findOne` accepts any\n * `WhereClause` and returns the full row).\n */\nexport interface PathsCanonicalReader {\n findOne(where: {\n path: string\n isCanonical: true\n /** A single locale, or an `IN (...)` set for the catch-all \"any configured locale\" check. */\n locale: string | { in: string[] }\n }): Promise<{ locale: string } | null>\n}\n\n/**\n * The read surface {@link collapsePathTarget} needs from the redirects table.\n * The real `ToolkitRedirectsClient` satisfies this.\n */\nexport interface RedirectsCollapseReader {\n findOne(where: {\n fromPath: string\n locale: string | null\n /** Excludes the row being updated so its own pre-write state can't mask a\n * self-referential collapse (M1 fix — see {@link collapsePathTarget}). */\n id?: { ne: string }\n }): Promise<{ target: RedirectTarget } | null>\n}\n\n/** Minimal client surface used to confirm an `entity`-kind target's id exists. */\nexport interface RedirectEntityClient {\n findById(id: string): Promise<unknown>\n}\n\n/**\n * Resolve an `AdminClient`-like reader for an entity TYPE NAME, or `undefined`\n * when that type isn't registered. Task 2 wires this to\n * `app.entities.get(name)` → `app.getClient(entity)`; injecting it keeps this\n * module a leaf (no `@murumets-ee/core` import).\n */\nexport type RedirectEntityClientResolver = (entity: string) => RedirectEntityClient | undefined\n\n/**\n * Rule (a) — reject a `from_path` under a reserved prefix (`/admin`, `/api`, …).\n * A manual redirect claiming a framework-owned path would be as broken as an\n * entity claiming it, and is the same security-relevant class as F013's gap, so\n * the same guard applies. Synchronous — pure string check. Throws\n * {@link RedirectReservedPrefixError}.\n */\nexport function assertFromPathNotReserved(args: {\n fromPath: string\n reservedPathPrefixes: readonly string[]\n}): void {\n const fromPath = normalizeRedirectPath(args.fromPath)\n const reserved = matchReservedPrefix(fromPath, args.reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new RedirectReservedPrefixError(fromPath, reserved)\n }\n}\n\n/**\n * Rule (b) — reject a `from_path` that matches a live canonical path (\"that URL\n * serves content\"; phase 04 §3). Redirecting away from a URL that already serves\n * content would hide the content.\n *\n * - A LOCALE-SPECIFIC redirect (`locale` a string) is checked against that one\n * locale's canonical rows.\n * - A CATCH-ALL redirect (`locale === null`) is checked against EVERY configured\n * locale (a single `locale IN (...)` lookup — `toolkit_paths` has no null-locale\n * rows, so a null-locale lookup would never match). Restricting to configured\n * locales avoids over-blocking on orphaned rows left by a removed locale.\n *\n * Throws {@link RedirectServesContentError} naming the specific conflicting locale.\n */\nexport async function assertFromPathNotServingContent(args: {\n fromPath: string\n locale: string | null\n configuredLocales: readonly string[]\n pathsClient: PathsCanonicalReader\n}): Promise<void> {\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n if (args.locale !== null) {\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: args.locale,\n })\n if (hit) throw new RedirectServesContentError(fromPath, args.locale, hit.locale)\n return\n }\n\n // Catch-all: does ANY configured locale serve this path? One IN-lookup covers\n // it (the query is bounded by the small, static configured-locale count).\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: { in: [...args.configuredLocales] },\n })\n if (hit) throw new RedirectServesContentError(fromPath, null, hit.locale)\n}\n\n/**\n * Rule (c) — collapse a `path`-kind target that itself points at another manual\n * redirect down to that redirect's (already-collapsed) target, so a stored `path`\n * target always points at CONTENT, never at another redirect row. \"Max one hop by\n * construction\" (phase 04 §3): every stored row's target is already fully\n * collapsed by this same rule, so a SINGLE lookup suffices — no loop, no\n * cycle-guard.\n *\n * Precedence, given the redirect being saved has its own `locale`:\n * - LOCALE-SPECIFIC save — prefer an exact-locale redirect at the target path,\n * else fall back to a catch-all (`locale = null`) redirect there.\n * - CATCH-ALL save (`locale === null`) — only a catch-all redirect at the target\n * path applies (a locale-specific row can't stand in for an all-locales\n * redirect).\n *\n * Non-`path` targets (entity / url) are returned unchanged without any lookup.\n * A `path` target that points at no redirect is returned unchanged (it points at\n * content, or a dead path — not this rule's concern).\n *\n * `excludeId` — pass the row's own id on UPDATE. Without it, a `PATCH` that\n * keeps `fromPath`/`locale` unchanged and sets a self-referential `path` target\n * would have this lookup match the row's own PRE-write state (the UPDATE hasn't\n * executed yet within the same transaction), silently returning its OLD target\n * instead of surfacing as a self-reference — the caller's write appears to\n * succeed (200) while the target silently doesn't change to what was requested.\n * Excluding the row's own id makes the lookup correctly report \"no redirect at\n * the target path\", which the caller's self-redirect guard then catches.\n */\nexport async function collapsePathTarget(args: {\n target: RedirectTarget\n locale: string | null\n redirectsClient: RedirectsCollapseReader\n excludeId?: string\n}): Promise<RedirectTarget> {\n if (args.target.kind !== 'path') return args.target\n const path = normalizeRedirectPath(args.target.path)\n const idFilter = args.excludeId === undefined ? {} : { id: { ne: args.excludeId } }\n\n // Prefer an exact-locale redirect (only meaningful for a locale-specific save).\n if (args.locale !== null) {\n const exact = await args.redirectsClient.findOne({\n fromPath: path,\n locale: args.locale,\n ...idFilter,\n })\n if (exact) return exact.target\n }\n // Fall back to (or, for a catch-all save, only consider) a catch-all redirect.\n const catchAll = await args.redirectsClient.findOne({ fromPath: path, locale: null, ...idFilter })\n if (catchAll) return catchAll.target\n\n // No redirect at the target path — leave the path target as-is.\n return { kind: 'path', path }\n}\n\n/**\n * Rule for `entity`-kind targets — confirm the referenced entity TYPE is\n * registered and the referenced ID exists. A redirect to an unknown type or a\n * nonexistent document can never resolve to a useful URL, so both are rejected at\n * save (CLAUDE.md \"validate at the boundary\") rather than surfacing as a dead link\n * at request time. No-op for `url` / `path` targets. Entity targets do NOT resolve\n * to a path here — that happens at REQUEST time in PR06 (phase 04 §3).\n *\n * Throws {@link RedirectUnknownEntityError} (unregistered type) or\n * {@link RedirectTargetNotFoundError} (type ok, id missing).\n */\nexport async function assertEntityTargetValid(args: {\n target: RedirectTarget\n resolveClient: RedirectEntityClientResolver\n}): Promise<void> {\n if (args.target.kind !== 'entity') return\n const { entity, id } = args.target\n\n const client = args.resolveClient(entity)\n if (client === undefined) throw new RedirectUnknownEntityError(entity)\n\n const found = await client.findById(id)\n if (found === null || found === undefined) throw new RedirectTargetNotFoundError(entity, id)\n}\n","/**\n * Manual-redirect admin CRUD routes — declarative `defineAdminRoute` entries\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3-4; F022:\n * backend-only, no admin-ui page in this PR).\n *\n * Mounted under prefix `redirects`. The URL surface:\n *\n * GET /api/admin/redirects ?locale=&limit=&offset= — list (paginated)\n * POST /api/admin/redirects { locale, fromPath, target, status? }\n * PATCH /api/admin/redirects/:id { locale?, fromPath?, target?, status? }\n * DELETE /api/admin/redirects/:id\n * GET /api/admin/redirects/history ?entityType=&entityId=&locale= — automatic\n * (rename) redirect history for one document\n *\n * **Registration shape** (per `defineAdminRoute`'s single-segment-path\n * constraint): PATCH + DELETE embed the redirect id directly after the prefix\n * (`/redirects/<uuid>`), so they register `path: '', matchAnyPath: true` and read\n * the id from `ctx.segments[0]`. `combineAdminRoutes`' dispatcher routes any\n * unmatched sub-path under `(prefix, method)` through the single catch-all when\n * no exact `path` claims `segments[0]` (see `makeDispatcher`). GET registers two\n * EXACT entries — `''` (list) and `'history'` — which take precedence over any\n * catch-all and never collide. POST registers `''` (create) only.\n *\n * **Authorization model:** every route gates on the SINGLE `redirect:manage`\n * permission (phase 04 §4 — one permission for all CRUD, not a per-verb split).\n * `defaultRoles: ['admin']`. The wrapper (`guardedHandler`) emits the\n * `permission.denied` audit on every 403 uniformly.\n *\n * **DB access** is resolved at REQUEST time (never at factory/module scope —\n * `getApp()` throws before boot; the factory runs at config-load under jiti/tsx).\n * Create / update / delete each run inside ONE `db.transaction` so the\n * `toolkit_redirects` row write and its `entity_refs` bookkeeping (`./refs.ts`)\n * commit — or roll back — atomically.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n type AuthUser,\n combineAdminRoutes,\n defineAdminRoute,\n requireRequestSite,\n safeAudit,\n} from '@murumets-ee/core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { z } from 'zod'\nimport { getContentConfig } from '../content-config-store.js'\nimport { isUniqueViolation } from '../paths/apply-path.js'\nimport { toolkitPathsTable } from '../paths/schema.js'\nimport {\n RedirectReservedPrefixError,\n RedirectSelfReferenceError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport { clearRedirectRefs, writeRedirectRef } from './refs.js'\nimport { toolkitRedirectsTable } from './schema.js'\n\n/**\n * The site these routes act on (multisite phase 03): redirects and the path\n * history they read are kept per site, so every route reads and writes the\n * request's site only — never a default on an app serving several sites.\n */\nfunction requestSite(): Promise<string> {\n return requireRequestSite('toolkit_redirects')\n}\n\nimport { type RedirectTarget, redirectTargetSchema } from './target.js'\nimport {\n assertEntityTargetValid,\n assertFromPathNotReserved,\n assertFromPathNotServingContent,\n collapsePathTarget,\n normalizeRedirectPath,\n type RedirectEntityClientResolver,\n} from './validation.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n/** `toolkit_paths.entity_type` is `varchar(100)`; same shape as queue's type filter. */\nconst ENTITY_TYPE_RE = /^[a-zA-Z0-9:_.-]{1,100}$/\n\n/** ONE permission gates every redirect CRUD verb (phase 04 §4). */\nconst REDIRECT_PERMISSION = 'redirect:manage'\nconst REDIRECT_DEFAULT_ROLES = ['admin'] as const\n\n// ---------------------------------------------------------------------------\n// HTTP helpers (same shape as queue/src/admin.ts)\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\nasync function parseJsonBody<S extends z.ZodTypeAny>(\n req: Request,\n schema: S,\n): Promise<{ data: z.infer<S> } | { response: Response }> {\n let raw: unknown\n try {\n raw = await req.json()\n } catch {\n return { response: errorJson('Invalid JSON body', 400) }\n }\n const result = schema.safeParse(raw)\n if (!result.success) {\n const issue = result.error.issues[0]\n const message = issue ? `${issue.path.join('.') || 'body'}: ${issue.message}` : 'Invalid body'\n return { response: errorJson(message, 400) }\n }\n return { data: result.data }\n}\n\n/**\n * Strict uint query-param parse — same regex + `Number.isSafeInteger` guard as\n * queue's `parseUintParam` (plain `Number()` silently coerces blanks / hex /\n * exponent / signed input; a long digit string overflows to `Infinity`).\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n/** Pagination offset cap — a deep `?offset=` forces a scan-and-discard pass. */\nconst MAX_OFFSET = 100_000\n/** History is inherently bounded (PR04 caps rename history per doc/locale), but keep a hard ceiling. */\nconst HISTORY_LIMIT = 100\n\nfunction auditActor(user: AuthUser): { userId: string; userName?: string } {\n return user.name === undefined ? { userId: user.id } : { userId: user.id, userName: user.name }\n}\n\n// ---------------------------------------------------------------------------\n// App / DB access — deferred to request time (getApp throws before boot; the\n// factory runs at config-load). Mirrors queue/src/admin.ts's getDb().\n// ---------------------------------------------------------------------------\n\nasync function getAppInstance() {\n const { getApp } = await import('@murumets-ee/core')\n return getApp()\n}\n\nasync function getDb(): Promise<PostgresJsDatabase> {\n return (await getAppInstance()).db.readWrite\n}\n\n/**\n * Build the `entity`-target existence resolver from the live app registry:\n * `app.entities.get(name)` → `app.getClient(entity)`. Passed into\n * `assertEntityTargetValid`, keeping `./validation.ts` a leaf (no core import).\n */\nasync function buildEntityResolver(): Promise<RedirectEntityClientResolver> {\n const app = await getAppInstance()\n return (name) => {\n const entity = app.entities.get(name)\n return entity ? app.getClient(entity) : undefined\n }\n}\n\n/**\n * Drizzle's transaction handle is structurally a `PgDatabase` but its generic\n * parameters don't line up with the `PostgresJsDatabase` alias `makeClient` /\n * `writeRedirectRef` expect, so the tx handle is cast at this single boundary —\n * the exact pattern `@murumets-ee/db`'s own `TableClient.transaction` uses\n * (`innerDb as unknown as PostgresJsDatabase`). One cast per transaction.\n */\ntype DrizzleTx = Parameters<Parameters<PostgresJsDatabase['transaction']>[0]>[0]\nfunction txAsDb(tx: DrizzleTx): PostgresJsDatabase {\n return tx as unknown as PostgresJsDatabase\n}\n\n// ---------------------------------------------------------------------------\n// Request-body schemas — validated at the boundary (CLAUDE.md). `createdBy` is\n// NEVER accepted from the body; it's set server-side from ctx.user.id.\n// ---------------------------------------------------------------------------\n\n/** `301 | 302` only. */\nconst statusSchema = z.union([z.literal(301), z.literal(302)])\n\n/** Locale-relative internal path — leading '/', bounded, same convention as `target.ts`'s `path` arm. */\nconst fromPathSchema = z.string().min(1).max(2048).startsWith('/')\n\nconst createBodySchema = z.object({\n /** BCP47 locale, or `null` = catch-all (applies to all locales). Membership vs. configured locales is checked in the handler. */\n locale: z.string().min(1).nullable(),\n fromPath: fromPathSchema,\n target: redirectTargetSchema,\n status: statusSchema.default(301),\n})\n\nconst updateBodySchema = z.object({\n locale: z.string().min(1).nullable().optional(),\n fromPath: fromPathSchema.optional(),\n target: redirectTargetSchema.optional(),\n status: statusSchema.optional(),\n})\n\n/**\n * Whitelist a redirect's locale against the CONFIGURED content locales\n * (deny-by-default; CLAUDE.md). `null` (catch-all) is always allowed. Returns a\n * 400 Response on rejection, or `null` when the locale is acceptable.\n */\nfunction rejectUnconfiguredLocale(locale: string | null): Response | null {\n if (locale === null) return null\n const configured = getContentConfig().locales.map((l) => l.code)\n if (!configured.includes(locale)) {\n return errorJson(`Unknown locale \"${locale}\" — not one of the configured content locales.`, 400)\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Error → HTTP status mapping. Conflict-with-existing-state errors → 409\n// (matches the codebase convention: ReferencedEntityError / CrossLocaleWriteError\n// / RestoreConflictError all → 409). Bad-target-in-payload → 400 (matches\n// EntityCapabilityError / InvalidLocaleError → 400). A 23505 escaping the tx (the\n// DB-level partial-unique backstop firing on a race) → 409, never a raw 500.\n// Anything else is rethrown so the api-handler's central catch logs + 500s.\n// ---------------------------------------------------------------------------\n\nfunction mapRedirectError(err: unknown): Response {\n if (err instanceof RedirectReservedPrefixError) {\n return json({ error: err.message, fromPath: err.fromPath, prefix: err.prefix }, 409)\n }\n if (err instanceof RedirectServesContentError) {\n return json(\n {\n error: err.message,\n fromPath: err.fromPath,\n locale: err.locale,\n conflictingLocale: err.conflictingLocale,\n },\n 409,\n )\n }\n if (err instanceof RedirectSelfReferenceError) {\n return json({ error: err.message, fromPath: err.fromPath, locale: err.locale }, 409)\n }\n if (err instanceof RedirectUnknownEntityError) {\n return json({ error: err.message, entity: err.entity }, 400)\n }\n if (err instanceof RedirectTargetNotFoundError) {\n return json({ error: err.message, entity: err.entity, id: err.id }, 400)\n }\n if (isUniqueViolation(err)) {\n return json({ error: 'A redirect already exists for this locale + source path.' }, 409)\n }\n // Unknown — let the api-handler's central catch log it and return 500.\n throw err\n}\n\n// ---------------------------------------------------------------------------\n// Core write path — shared by create + update. Runs the full save-time\n// validation chain (normalize → reserved-prefix → entity-target → serves-content\n// → collapse → self-redirect guard) then writes the row + entity_refs inside ONE\n// transaction. Returns the written row, or `null` when an UPDATE matched no row.\n// ---------------------------------------------------------------------------\n\n/** The stored redirect row (typed via the DefinedTable's client). */\ntype RedirectRow = Awaited<\n ReturnType<ReturnType<typeof toolkitRedirectsTable.makeClient>['insert']>\n>\n\nasync function runRedirectWrite(args: {\n /** `null` = create; a UUID = update that id. */\n id: string | null\n locale: string | null\n fromPath: string\n target: RedirectTarget\n status: number\n userId: string\n resolveClient: RedirectEntityClientResolver\n}): Promise<RedirectRow | null> {\n const cfg = getContentConfig()\n const reservedPathPrefixes = cfg.reservedPathPrefixes ?? []\n const configuredLocales = cfg.locales.map((l) => l.code)\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n // 1. Reserved-prefix rejection (pure string check; security).\n assertFromPathNotReserved({ fromPath, reservedPathPrefixes })\n\n // 2. Entity-target existence — a non-tx AdminClient read (soft pre-check; the\n // row it validates isn't part of this write's transaction).\n await assertEntityTargetValid({ target: args.target, resolveClient: args.resolveClient })\n\n const db = await getDb()\n // Resolved before the transaction: the single-site lookup reads the pool.\n const site = await requestSite()\n return db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n const redirectsClient = toolkitRedirectsTable.makeClient(tx, site)\n const pathsClient = toolkitPathsTable.makeClient(tx, site)\n\n // 3. \"That URL serves content\" rejection — reads toolkit_paths in-tx.\n await assertFromPathNotServingContent({\n fromPath,\n locale: args.locale,\n configuredLocales,\n pathsClient,\n })\n\n // 4. Collapse a path-kind target that points at another redirect (one hop).\n // excludeId (update only) keeps the row's own pre-write state from\n // masking a self-referential collapse — see collapsePathTarget's doc.\n const collapsed = await collapsePathTarget({\n target: args.target,\n locale: args.locale,\n redirectsClient,\n ...(args.id === null ? {} : { excludeId: args.id }),\n })\n\n // 5. Self-redirect guard — a collapsed path target that points back at this\n // very from_path (same locale) is a no-op loop; reject rather than store.\n if (collapsed.kind === 'path' && normalizeRedirectPath(collapsed.path) === fromPath) {\n throw new RedirectSelfReferenceError(fromPath, args.locale)\n }\n\n // 6. Write the row + entity_refs bookkeeping, atomically.\n if (args.id === null) {\n const created = await redirectsClient.insert({\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n createdBy: args.userId,\n })\n await writeRedirectRef(tx, created.id, collapsed)\n return created\n }\n\n const updated = await redirectsClient.update(\n { id: args.id },\n {\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n updatedAt: new Date(),\n },\n )\n if (!updated) return null // row vanished between the pre-tx fetch and here.\n // Delete-then-insert refs (not diff) — matches ./refs.ts's documented usage.\n await clearRedirectRefs(tx, args.id)\n await writeRedirectRef(tx, args.id, collapsed)\n return updated\n })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleList: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const localeParam = url.searchParams.get('locale')\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 50), 1), 200)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitRedirectsTable.makeClient(await getDb(), await requestSite())\n const items =\n localeFilter !== undefined\n ? await client.findMany({\n where: { locale: localeFilter },\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n : await client.findMany({\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n const total =\n localeFilter !== undefined ? await client.count({ locale: localeFilter }) : await client.count()\n\n return json({ items, total })\n}\n\nconst handleCreate: AdminRouteHandler = async (req, ctx) => {\n const parsed = await parseJsonBody(req, createBodySchema)\n if ('response' in parsed) return parsed.response\n const body = parsed.data\n\n const localeRejected = rejectUnconfiguredLocale(body.locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id: null,\n locale: body.locale,\n fromPath: body.fromPath,\n target: body.target,\n status: body.status,\n userId: ctx.user.id,\n resolveClient,\n })\n // Create always returns a row (insert never matches \"nothing\").\n if (!row) return errorJson('Failed to create redirect', 500)\n safeAudit(ctx, {\n action: 'redirect.created',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row }, 201)\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleUpdate: AdminRouteHandler = async (req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const parsed = await parseJsonBody(req, updateBodySchema)\n if ('response' in parsed) return parsed.response\n const patch = parsed.data\n\n // Fetch the existing row so a partial PATCH merges onto its current state.\n const existing = await toolkitRedirectsTable\n .makeClient(await getDb(), await requestSite())\n .findOne({ id })\n if (!existing) return errorJson('Not found', 404)\n\n // Merge — only provided keys change. `locale` may be explicitly `null`\n // (switch to catch-all), so key-presence (!== undefined), not truthiness,\n // decides whether to keep the existing value.\n const locale = patch.locale !== undefined ? patch.locale : existing.locale\n const fromPath = patch.fromPath !== undefined ? patch.fromPath : existing.fromPath\n const target = patch.target !== undefined ? patch.target : existing.target\n const status = patch.status !== undefined ? patch.status : existing.status\n\n // Re-validate the RESULTING merged state (e.g. changing only `locale` can\n // newly conflict with content served in the new locale).\n const localeRejected = rejectUnconfiguredLocale(locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id,\n locale,\n fromPath,\n target,\n status,\n userId: ctx.user.id,\n resolveClient,\n })\n if (!row) return errorJson('Not found', 404)\n safeAudit(ctx, {\n action: 'redirect.updated',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row })\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleDelete: AdminRouteHandler = async (_req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const db = await getDb()\n const site = await requestSite()\n const deleted = await db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n // Delete (confined to the request's site) THEN clear its refs, same tx — no\n // dangling entity_refs row survives, and a redirect id of ANOTHER site\n // deletes nothing and clears nothing (multisite phase 03).\n const row = await toolkitRedirectsTable.makeClient(tx, site).delete({ id })\n if (row) await clearRedirectRefs(tx, id)\n return row\n })\n if (!deleted) return errorJson('Not found', 404)\n\n safeAudit(ctx, {\n action: 'redirect.deleted',\n entityType: 'redirect',\n entityId: id,\n ...auditActor(ctx.user),\n metadata: { locale: deleted.locale, fromPath: deleted.fromPath },\n })\n return json({ ok: true })\n}\n\n/**\n * Read-only automatic-redirect history for ONE document — the demoted\n * (`is_canonical = false`) `toolkit_paths` rows a rename leaves behind.\n * `entityType` + `entityId` are REQUIRED; `locale` optional. No write path.\n */\nconst handleHistory: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const entityType = url.searchParams.get('entityType')\n const entityId = url.searchParams.get('entityId')\n if (!entityType || !ENTITY_TYPE_RE.test(entityType)) {\n return errorJson('entityType is required', 400)\n }\n if (!entityId || !UUID_RE.test(entityId)) {\n return errorJson('entityId is required and must be a UUID', 400)\n }\n\n const localeParam = url.searchParams.get('locale')\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitPathsTable.makeClient(await getDb(), await requestSite())\n const items = await client.findMany({\n where: {\n entityType,\n entityId,\n isCanonical: false,\n ...(localeFilter !== undefined && { locale: localeFilter }),\n },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: HISTORY_LIMIT,\n })\n return json({ items })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the manual-redirect admin API routes.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry for prefix\n * `'redirects'` with GET/POST/PATCH/DELETE handlers. Every entry gates on\n * `redirect:manage` (defaultRoles `['admin']`); the permission is\n * auto-registered into the catalog by `defineAdminRoute`.\n */\nexport function redirectRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'List manual URL redirects (paginated, optional locale filter).',\n tokens: 'accept',\n handler: handleList,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: 'history',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Read-only automatic-redirect (rename) history for one document.',\n tokens: 'accept',\n handler: handleHistory,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'POST',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Create a manual URL redirect.',\n tokens: 'accept',\n handler: handleCreate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'PATCH',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Update a manual URL redirect by id.',\n tokens: 'accept',\n handler: handleUpdate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'DELETE',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Delete a manual URL redirect by id.',\n tokens: 'accept',\n confirm: true,\n handler: handleDelete,\n }),\n ])\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { l as RoutingStrategy } from "./types-wMTglM4j.mjs";
1
+ import { l as RoutingStrategy } from "./types-BOk9tg_9.mjs";
2
2
  import { Behavior } from "@murumets-ee/entity";
3
3
 
4
4
  //#region src/i18n-settings.d.ts
@@ -152,4 +152,4 @@ declare function localePrefixForStrategy(strategy: RoutingStrategy): 'always' |
152
152
  declare function prefixDefaultLocaleForStrategy(strategy: RoutingStrategy): boolean;
153
153
  //#endregion
154
154
  export { ROUTING_STRATEGIES as a, Locale as i, prefixDefaultLocaleForStrategy as n, i18nSettings as o, Message as r, localePrefixForStrategy as t };
155
- //# sourceMappingURL=routing-strategy-DVWB7qPp.d.mts.map
155
+ //# sourceMappingURL=routing-strategy-CAG3tZP0.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"routing-strategy-DVWB7qPp.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;;;AAiBA;;;;AAAwF;AAExF;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,yCAAY,kBAAA;;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;EA0CjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cCTW,OAAA,gCAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBClCJ,uBAAA,CACd,QAAyB,EAAf,eAAe;;AFA3B;;;;iBEiBgB,8BAAA,CAA+B,QAAyB,EAAf,eAAe"}
1
+ {"version":3,"file":"routing-strategy-CAG3tZP0.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;;;AAiBA;;;;AAAwF;AAExF;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,yCAAY,kBAAA;;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;EA0CjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cCTW,OAAA,gCAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBClCJ,uBAAA,CACd,QAAyB,EAAf,eAAe;;AFA3B;;;;iBEiBgB,8BAAA,CAA+B,QAAyB,EAAf,eAAe"}
@@ -1,4 +1,4 @@
1
- import { t as VersionableFields } from "./versionable-D2JjselN.mjs";
1
+ import { t as VersionableFields } from "./versionable-CRvV87nZ.mjs";
2
2
  import { Behavior, EntityAdminConfig } from "@murumets-ee/entity";
3
3
 
4
4
  //#region src/entities/site-chrome.d.ts
@@ -95,4 +95,4 @@ declare function siteChromeEntity(options?: SiteChromeEntityOptions): import("@m
95
95
  }, "site_chrome", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, Behavior<VersionableFields, string>, ...Behavior<Record<never, never>, string>[]]>;
96
96
  //#endregion
97
97
  export { siteChromeEntity as a, SiteChromeEntityOptions as i, CHROME_PLACEMENT_LABELS as n, ChromePlacement as r, CHROME_PLACEMENTS as t };
98
- //# sourceMappingURL=site-chrome-BiV3zhw0.d.mts.map
98
+ //# sourceMappingURL=site-chrome-DNCcePSR.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"site-chrome-BiV3zhw0.d.mts","names":[],"sources":["../src/entities/site-chrome.ts"],"mappings":";;;;;;;;;;;cA0Ga,iBAAA;;KAGD,eAAA,WAA0B,iBAAiB;;cAG1C,uBAAA,EAAyB,QAAA,CAAS,MAAA,CAAO,eAAA;;UAMrC,uBAAA;;;;;;;;;;EAUf,QAAA;;EAEA,UAAA;;EAEA,SAAA;;;;;;EAMA,WAAA;;EAEA,WAAA;;;;;;;;;;;;;;EAcA,SAAA,YAAqB,QAAA,CAAS,MAAA;;;;;;;;;EAS9B,KAAA;IACE,cAAA,GAAiB,iBAAA;EAAA;AAAA;;;;;;;;;;;iBAsCL,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
1
+ {"version":3,"file":"site-chrome-DNCcePSR.d.mts","names":[],"sources":["../src/entities/site-chrome.ts"],"mappings":";;;;;;;;;;;cA0Ga,iBAAA;;KAGD,eAAA,WAA0B,iBAAiB;;cAG1C,uBAAA,EAAyB,QAAA,CAAS,MAAA,CAAO,eAAA;;UAMrC,uBAAA;;;;;;;;;;EAUf,QAAA;;EAEA,UAAA;;EAEA,SAAA;;;;;;EAMA,WAAA;;EAEA,WAAA;;;;;;;;;;;;;;EAcA,SAAA,YAAqB,QAAA,CAAS,MAAA;;;;;;;;;EAS9B,KAAA;IACE,cAAA,GAAiB,iBAAA;EAAA;AAAA;;;;;;;;;;;iBAsCL,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./content-config-store-8omQF5p5.mjs";import{t}from"./registry-CaPbY5lm.mjs";import"./locale-utils-B0mFoV47.mjs";import{blockTreeFieldsOf as n}from"@murumets-ee/entity";import{getApp as r,getContext as i,isTokenDerivedDoor as a}from"@murumets-ee/core";import{column as o,defineTable as s}from"@murumets-ee/db";import{AsyncResource as c}from"node:async_hooks";import{createHash as l}from"node:crypto";import{isProseText as u,treeTextStrings as d}from"@murumets-ee/blocks/translation-text";const f=s({name:`toolkit_translation_provenance`,columns:{id:o.uuid({primaryKey:!0,defaultRandom:!0}),entityType:o.varchar({length:100,notNull:!0,pgName:`entity_type`}),entityId:o.varchar({length:255,notNull:!0,pgName:`entity_id`}),locale:o.varchar({length:10,notNull:!0}),contentHash:o.varchar({length:64,notNull:!0,pgName:`content_hash`}),contentChangedAt:o.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`content_changed_at`}),sourceLocale:o.varchar({length:10,pgName:`source_locale`}),sourceHash:o.varchar({length:64,pgName:`source_hash`}),method:o.varchar({length:10}),reviewedAt:o.timestamp({withTimezone:!0,pgName:`reviewed_at`}),reviewedBy:o.varchar({length:255,pgName:`reviewed_by`}),updatedAt:o.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`}),updatedBy:o.varchar({length:255,pgName:`updated_by`})},unique:[{on:[`entityType`,`entityId`,`locale`],name:`uq_translation_provenance_doc_locale`}]}),p=f.table;function m(e){return e.allFields}function h(e){let t=[];for(let[n,r]of Object.entries(m(e)))r.translatable===!0&&(r.type===`text`||r.type===`richtext`)&&t.push(n);return t}function g(e){let t=m(e);return n(e).map(e=>e.name).filter(e=>t[e]?.translatable===!0)}function _(e,t){let n=[];for(let r of h(e)){let e=t[r];typeof e==`string`&&u(e)&&n.push([r,e])}return n}function v(e){return h(e).length>0||g(e).length>0}const y=/<\/?(?:a|abbr|b|code|em|i|mark|s|small|span|strong|sub|sup|u)(?:\s[^<>]*)?\/?>/gi;function b(e){return e.replace(y,``).replace(/<[^<>]*>/g,` `)}function x(e){return e.replace(/\s+/g,` `).trim()}function S(e,t){let n=[],r=m(e);for(let i of h(e)){let e=t[i];if(typeof e!=`string`||!u(e))continue;let a=r[i]?.type===`richtext`?b(e):e;n.push(`${i}=${x(a)}`)}for(let r of g(e)){let e=t[r];if(Array.isArray(e))for(let t of d(e)){let e=x(t);e!==``&&n.push(e)}}return n.sort(),l(`sha256`).update(JSON.stringify(n)).digest(`hex`)}function C(e,t){return e===`_`?t:e}function w(e=r().db.readWrite){return f.makeClient(e)}function T(){try{return r()}catch{return}}function E(e){let t=e.method;return{locale:String(e.locale),contentHash:String(e.contentHash),contentChangedAt:e.contentChangedAt,sourceLocale:typeof e.sourceLocale==`string`?e.sourceLocale:null,sourceHash:typeof e.sourceHash==`string`?e.sourceHash:null,method:t===`ai`||t===`human`||t===`copy`?t:null,reviewedAt:e.reviewedAt??null,reviewedBy:typeof e.reviewedBy==`string`?e.reviewedBy:null}}async function D(e,n,i,a,o){let s=r(),c=i===a,l=await s.getClient(e).findById(n,{...c?{}:{locale:i,defaultLocale:a}});if(!l)return null;if(o!==void 0)return{row:l,working:{...l,...o},drafted:!0};let{drafts:u}=t(e.name);if(!u)return{row:l,working:l,drafted:!1};let{siteForDocumentType:d}=await import(`./document-site-B8tmEqLw.mjs`).then(e=>e.t),f=(await u.makeClient(s.db.readWrite,await d(e.name)).findOne({entityId:n,locale:c?`_`:i}))?.data;return f&&typeof f==`object`?{row:l,working:{...l,...f},drafted:!0}:{row:l,working:l,drafted:!1}}async function O(t){let{entityName:n,entityId:r}=t,o=T();if(o)try{let s=o.entities.get(n);if(!s||!v(s))return;let c=e().defaultLocale,l=C(t.locale,c),u=await D(s,r,l,c,t.draftData===void 0?void 0:{...t.draftData});if(!u)return;let d=S(s,u.working),f=w(),p=new Date,m={entityType:s.name,entityId:r};if(t.contentOnly===!0||l===c&&t.sourceLocale===void 0){let e=t.contentOnly===!0||t.reverted===!0?void 0:t.userId;await k(f,{...m,locale:l},d,p,e);return}let h=C(t.sourceLocale??c,c);if(h===l)return;let g=t.sourceHash;if(g===void 0){let e=await f.findOne({...m,locale:h});if(e)g=String(e.contentHash);else{let e=await D(s,r,h,c);if(!e)return;g=S(s,e.working),await k(f,{...m,locale:h},g,p,void 0)}}let _=await f.findOne({...m,locale:l}),y=_===null?u.drafted?S(s,u.row)!==d:d!==g:_.contentHash!==d,b=t.method===`ai`||y&&t.reverted!==!0&&a(i()?.door),x=t.userId!==void 0&&t.reverted!==!0&&(_===null||_.contentHash!==d),T=_?.method===`ai`,E=b||T?`ai`:d===g?`copy`:`human`;await f.upsert({...m,locale:l,contentHash:d,contentChangedAt:p,sourceLocale:h,sourceHash:g,method:E,reviewedAt:null,reviewedBy:null,updatedAt:p,updatedBy:t.userId??null},{target:[`entityType`,`entityId`,`locale`],set:{contentHash:d,...(_===null||_.contentHash!==d)&&{contentChangedAt:p},sourceLocale:h,sourceHash:g,method:E,...b&&{reviewedAt:null,reviewedBy:null},updatedAt:p,...x&&{updatedBy:t.userId}}})}catch(e){o.logger.warn({err:e,entity:n,entityId:r,locale:t.locale},`translation provenance: could not record a locale save`)}}async function k(e,t,n,r,i){let a=await e.findOne(t);a&&a.contentHash===n||await e.upsert({...t,contentHash:n,contentChangedAt:r,updatedAt:r,updatedBy:i??null},{target:[`entityType`,`entityId`,`locale`],set:{contentHash:n,contentChangedAt:r,updatedAt:r,...i!==void 0&&{updatedBy:i}}})}async function A(e){let t=w(),n={entityType:e.entityType,entityId:e.entityId,locale:e.locale},r=await t.findOne(n);if(!r)return`none`;if(e.refuseSelf===!0&&r.updatedBy===e.userId)return`self`;let i=new Date;return await t.update(n,{reviewedAt:i,reviewedBy:e.userId,updatedAt:i}),`reviewed`}async function j(e,t,n){let r=new Map;if(t.length===0)return r;let i=await w(n).findMany({where:{entityType:{eq:e},entityId:{in:[...t]}}});for(let e of i){let t=String(e.entityId),n=r.get(t);n||(n=new Map,r.set(t,n));let i=E(e);n.set(i.locale,i)}return r}function M(e,t){let n=e?.get(t);if(!n)return null;let r=n.sourceLocale===null?void 0:e?.get(n.sourceLocale),i=r!==void 0&&n.sourceHash!==null&&r.contentHash!==n.sourceHash;return{method:n.method,sourceLocale:n.sourceLocale,outdated:i,outdatedSince:i&&r?new Date(r.contentChangedAt).toISOString():null,machineUnreviewed:n.method===`ai`&&n.reviewedAt===null,reviewedAt:n.reviewedAt?new Date(n.reviewedAt).toISOString():null}}const N=new Map,P=new Map;function F(){for(let[e,t]of N){if(P.size>=2)return;if(P.has(e))continue;N.delete(e);let n=t.run().finally(()=>{P.delete(e),F()});P.set(e,n)}}function I(e){let t=T();if(!t)return;let n=R(e.entityName,e.entityId,e.locale);if(!N.has(n)&&N.size>=200){t.logger.warn({entity:e.entityName,entityId:e.entityId,locale:e.locale,waiting:N.size},`translation provenance: too many recordings waiting — this save was not recorded`);return}N.set(n,{run:c.bind(()=>O(e))}),F()}const L=e=>{if(e.action===`delete`)return;let t=T();if(!t)return;let n=t.entities.get(e.entityName);!n||!v(n)||I({entityName:e.entityName,entityId:e.id,locale:e.locale??`_`,userId:i()?.user?.id})};function R(e,t,n){return`${e}:${t}:${n}`}async function z(e,t,n,r=3e3){let i=R(e,t,n),a=Date.now()+r;for(;Date.now()<a;){let e=P.get(i);if(e!==void 0){await Promise.race([e,new Promise(e=>setTimeout(e,50))]);continue}if(!N.has(i))return;await new Promise(e=>setTimeout(e,5))}}async function B(){for(;P.size>0||N.size>0;)await new Promise(e=>setTimeout(e,5))}export{z as a,O as c,g as d,M as f,v as i,_ as l,p as m,I as n,A as o,L as p,j as r,B as s,S as t,h as u};
2
+ //# sourceMappingURL=translation-provenance-B41lEDM-.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translation-provenance-B41lEDM-.mjs","names":[],"sources":["../src/schema/translation-provenance.ts","../src/translation-provenance.ts"],"sourcesContent":["/**\n * Translation provenance — what each locale of a document was last saved FROM\n * (agent-access D017).\n *\n * One row per (document, locale), the source locale included:\n *\n * - `contentHash` is a hash of that locale's own reader text as of its last\n * save. For the SOURCE locale this is the value every translation is\n * compared against, and `contentChangedAt` is when it last moved — the date\n * in \"Outdated since …\".\n * - `sourceLocale` / `sourceHash` record, for a translation, which locale it\n * was made from and that locale's `contentHash` at the time. A translation\n * is outdated when the source's `contentHash` no longer equals it.\n * - `method` is how the text got there (`ai` | `human` | `copy`); machine\n * output is \"not reviewed\" until `reviewedAt` is set.\n *\n * Storing the source's hash on its OWN row is what keeps \"outdated\" a\n * comparison of two stored values: a list or a report never has to re-read and\n * re-hash a document to answer it.\n *\n * Shared, not per-entity (like `toolkit_content_locks`). A row is addressed by\n * a document's uuid and is only ever read for a document the caller has\n * already been handed by its own, site-confined client.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const translationProvenanceTable = defineTable({\n name: 'toolkit_translation_provenance',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n entityId: column.varchar({ length: 255, notNull: true, pgName: 'entity_id' }),\n /** A real locale code — the default locale by its code, never `'_'`. */\n locale: column.varchar({ length: 10, notNull: true }),\n contentHash: column.varchar({ length: 64, notNull: true, pgName: 'content_hash' }),\n contentChangedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'content_changed_at',\n }),\n sourceLocale: column.varchar({ length: 10, pgName: 'source_locale' }),\n sourceHash: column.varchar({ length: 64, pgName: 'source_hash' }),\n method: column.varchar({ length: 10 }),\n reviewedAt: column.timestamp({ withTimezone: true, pgName: 'reviewed_at' }),\n // A user id — better-auth ids are random strings, never uuids.\n reviewedBy: column.varchar({ length: 255, pgName: 'reviewed_by' }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n updatedBy: column.varchar({ length: 255, pgName: 'updated_by' }),\n },\n // Named explicitly: drizzle's default\n // (`toolkit_translation_provenance_entity_type_entity_id_locale_unique`) is\n // 66 characters, over Postgres's 63-character identifier limit.\n unique: [\n { on: ['entityType', 'entityId', 'locale'], name: 'uq_translation_provenance_doc_locale' },\n ],\n})\n\nexport const toolkitTranslationProvenance = translationProvenanceTable.table\n","/**\n * Translation provenance — recording what each locale was saved from, and\n * reading \"outdated\" / \"machine translated, not reviewed\" back\n * (agent-access D017).\n *\n * WHAT IS HASHED. A locale's reader TEXT, as the editor would show it: the\n * stored row with that locale's draft laid over it. Text means the entity's\n * translatable `text` / `richtext` fields and the text of every block tree\n * (`treeTextStrings`, the same walk machine translation uses). The strings are\n * hashed as a SORTED list, so moving a block changes nothing; an image, a link\n * or a design field is not text, so swapping one changes nothing either.\n * Slugs are derived, not written, and are left out.\n *\n * WHERE IT IS RECORDED. No single hook sees every locale save (R016), so there\n * are two:\n *\n * - the DRAFT writers record for themselves — this is every edit a person\n * makes to publishable content. `mergeDraft` / `saveDraft` / `deleteDraft`\n * await {@link recordLocaleSave}; the block editor's\n * `DocumentDraftStore.writeDraft` runs inside op-commit's transaction, so it\n * QUEUES the recording ({@link enqueueLocaleSave}) instead of awaiting it;\n * - a write that lands on the live row without a draft (a generic PATCH, a\n * version restore, a seeder, a non-publishable entity) reaches\n * {@link translationProvenanceWriteListener} through\n * `'entity.writeListeners'`.\n *\n * Publish needs no recording of its own: it moves the draft's text onto the\n * row, and the working text — draft over row — is the same before and after.\n *\n * NEVER FAILS A SAVE, AND NEVER HOLDS ONE UP. Recording runs on its own\n * connection, outside the caller's transaction — which also means a save that\n * rolls back can leave its hash recorded until the next save corrects it — and\n * every failure is logged and dropped: a missing\n * provenance row reads as \"unknown\", which is what every document saved before\n * this table existed already is.\n */\n\nimport { AsyncResource } from 'node:async_hooks'\nimport { createHash } from 'node:crypto'\nimport { isProseText, treeTextStrings } from '@murumets-ee/blocks/translation-text'\nimport { getApp, getContext, isTokenDerivedDoor } from '@murumets-ee/core'\nimport { blockTreeFieldsOf, type Entity, type FieldConfig } from '@murumets-ee/entity'\nimport type { EntityWriteObserver } from '@murumets-ee/entity/admin'\nimport { getContentConfig } from './content-config-store.js'\nimport { DEFAULT_LOCALE_SENTINEL } from './locale-utils.js'\nimport { getContentTables } from './schema/registry.js'\nimport { translationProvenanceTable } from './schema/translation-provenance.js'\n\n/** How a locale's text got there. */\nexport type TranslationMethod = 'ai' | 'human' | 'copy'\n\n/** One locale's stored provenance. */\nexport interface TranslationProvenance {\n locale: string\n contentHash: string\n contentChangedAt: Date\n sourceLocale: string | null\n sourceHash: string | null\n method: TranslationMethod | null\n reviewedAt: Date | null\n reviewedBy: string | null\n}\n\ntype Row = Record<string, unknown>\ntype Fields = Readonly<Record<string, FieldConfig>>\n\nfunction fieldsOf(entity: Pick<Entity, 'allFields'>): Fields {\n return entity.allFields as Fields\n}\n\n/**\n * The entity's translatable fields whose value is prose: `text` and `richtext`.\n * A slug is derived from its source field, and a select, a reference or a\n * media id is not text. The translation engine translates exactly these.\n */\nexport function translatableTextFields(entity: Pick<Entity, 'allFields'>): string[] {\n const out: string[] = []\n for (const [name, config] of Object.entries(fieldsOf(entity))) {\n if (config.translatable !== true) continue\n if (config.type === 'text' || config.type === 'richtext') out.push(name)\n }\n return out\n}\n\n/** The entity's translatable block-tree fields. */\nexport function translatableTreeFields(entity: Pick<Entity, 'allFields'>): string[] {\n const fields = fieldsOf(entity)\n return blockTreeFieldsOf(entity as Entity)\n .map((ref) => ref.name)\n .filter((name) => fields[name]?.translatable === true)\n}\n\n/** Whether a document of this entity has any text a translation could go stale against. */\n/**\n * The prose a document's translatable text fields hold right now: `[field,\n * text]` for every field whose value is a sentence — not an address (a\n * translatable `canonicalUrl` is a `text` field too) and not empty.\n */\nexport function translatableFieldTexts(\n entity: Pick<Entity, 'allFields'>,\n data: Readonly<Record<string, unknown>>,\n): [string, string][] {\n const out: [string, string][] = []\n for (const name of translatableTextFields(entity)) {\n const value = data[name]\n if (typeof value === 'string' && isProseText(value)) out.push([name, value])\n }\n return out\n}\n\nexport function hasTranslatableText(entity: Pick<Entity, 'allFields'>): boolean {\n return translatableTextFields(entity).length > 0 || translatableTreeFields(entity).length > 0\n}\n\nconst INLINE_TAG =\n /<\\/?(?:a|abbr|b|code|em|i|mark|s|small|span|strong|sub|sup|u)(?:\\s[^<>]*)?\\/?>/gi\n\n/**\n * The words of an HTML string. An INLINE tag disappears (bold in the middle of\n * a word, or before a comma, is the same words); any other tag is a break\n * between words.\n */\nfunction htmlText(html: string): string {\n return html.replace(INLINE_TAG, '').replace(/<[^<>]*>/g, ' ')\n}\n\nfunction squash(text: string): string {\n return text.replace(/\\s+/g, ' ').trim()\n}\n\n/**\n * Hash the reader text of one locale's data (a row, or a row with a draft laid\n * over it). Order-independent across blocks; whitespace-insensitive.\n */\nexport function documentTextHash(entity: Pick<Entity, 'allFields'>, data: Row): string {\n const parts: string[] = []\n const fields = fieldsOf(entity)\n for (const name of translatableTextFields(entity)) {\n const value = data[name]\n if (typeof value !== 'string' || !isProseText(value)) continue\n // An entity `richtext` column is an HTML string: its tags are not text, so\n // changing a link's target or making a word bold is not a text change.\n const text = fields[name]?.type === 'richtext' ? htmlText(value) : value\n parts.push(`${name}=${squash(text)}`)\n }\n for (const name of translatableTreeFields(entity)) {\n const value = data[name]\n if (!Array.isArray(value)) continue\n for (const text of treeTextStrings(value as never)) {\n const squashed = squash(text)\n if (squashed !== '') parts.push(squashed)\n }\n }\n parts.sort()\n return createHash('sha256').update(JSON.stringify(parts)).digest('hex')\n}\n\n/** `'_'` → the configured default locale's code; anything else unchanged. */\nfunction realLocale(locale: string, defaultLocale: string): string {\n return locale === DEFAULT_LOCALE_SENTINEL ? defaultLocale : locale\n}\n\ntype Db = Parameters<typeof translationProvenanceTable.makeClient>[0]\n\nfunction provenanceClient(db: Db = getApp().db.readWrite) {\n return translationProvenanceTable.makeClient(db)\n}\n\n/** The app, or `undefined` in a realm that has none (a unit test of a neighbouring module). */\nfunction appOrNone(): ReturnType<typeof getApp> | undefined {\n try {\n return getApp()\n } catch {\n // No app in this realm — there is no database to record against either.\n return undefined\n }\n}\n\nfunction toProvenance(row: Row): TranslationProvenance {\n const method = row.method\n return {\n locale: String(row.locale),\n contentHash: String(row.contentHash),\n contentChangedAt: row.contentChangedAt as Date,\n sourceLocale: typeof row.sourceLocale === 'string' ? row.sourceLocale : null,\n sourceHash: typeof row.sourceHash === 'string' ? row.sourceHash : null,\n method: method === 'ai' || method === 'human' || method === 'copy' ? method : null,\n reviewedAt: (row.reviewedAt as Date | null | undefined) ?? null,\n reviewedBy: typeof row.reviewedBy === 'string' ? row.reviewedBy : null,\n }\n}\n\n/**\n * One locale's stored row (locale-merged) and its working data — the row with\n * that locale's draft, if any, laid over it. `null` when the caller cannot read\n * the row.\n */\nasync function readWorkingData(\n entity: Entity,\n entityId: string,\n locale: string,\n defaultLocale: string,\n draftData?: Row,\n): Promise<{ row: Row; working: Row; drafted: boolean } | null> {\n const app = getApp()\n const isDefault = locale === defaultLocale\n const row = (await app.getClient(entity).findById(entityId, {\n ...(isDefault ? {} : { locale, defaultLocale }),\n })) as Row | null\n if (!row) return null\n if (draftData !== undefined) return { row, working: { ...row, ...draftData }, drafted: true }\n const { drafts } = getContentTables(entity.name)\n if (!drafts) return { row, working: row, drafted: false }\n const { siteForDocumentType } = await import('./internal/document-site.js')\n const draft = await drafts\n .makeClient(app.db.readWrite, await siteForDocumentType(entity.name))\n .findOne({ entityId, locale: isDefault ? DEFAULT_LOCALE_SENTINEL : locale })\n const data = draft?.data\n return data && typeof data === 'object'\n ? { row, working: { ...row, ...(data as Row) }, drafted: true }\n : { row, working: row, drafted: false }\n}\n\n/** What {@link recordLocaleSave} is told about the save. */\nexport interface LocaleSaveInput {\n /** The entity type. One the app does not know is ignored. */\n entityName: string\n entityId: string\n /** The saved locale — a real code, or `'_'` for the default locale. */\n locale: string\n /** Who saved. */\n userId?: string | undefined\n /**\n * The locale's whole draft AS JUST WRITTEN, when the caller holds it. A draft\n * writer inside a transaction must pass it: this module reads on its own\n * connection and would otherwise see the draft as it was before the save.\n */\n draftData?: Readonly<Record<string, unknown>> | undefined\n /**\n * `'ai'` when the caller KNOWS the text is machine output (the translation\n * engine). Left out, a save through a token-derived door is `'ai'` too — an\n * agent typing a translation is a machine translating — and a person's save\n * is `'human'`, or `'copy'` when the text is identical to the source's.\n */\n method?: 'ai' | undefined\n /**\n * This save REMOVED text rather than wrote it (a draft was discarded). It is\n * recorded — the working text changed back to the row's — but it is never\n * machine output, whoever did it: nobody wrote a word.\n */\n reverted?: boolean | undefined\n /**\n * Refresh this locale's TEXT HASH only — never its method, its source or its\n * review. For a locale that was READ as a source, not written: without it a\n * non-default source would be re-judged as a translation, under the caller's\n * door, and a hand-written Russian page an agent translated FROM would be\n * recorded as machine output.\n */\n contentOnly?: boolean | undefined\n /** The locale the text was translated from. Defaults to the default locale. */\n sourceLocale?: string | undefined\n /**\n * The source's text hash AS TRANSLATED, when the caller translated something\n * other than the source's current working text (its published row).\n */\n sourceHash?: string | undefined\n}\n\n/**\n * Record one locale save. Reads that locale's working text, stores its hash,\n * and — for a translation — the source hash it now corresponds to.\n *\n * Machine output stays `ai` and unreviewed through later human edits: fixing a\n * typo is not a review. Only \"Mark reviewed\" ({@link markTranslationReviewed})\n * clears it, and a fresh machine translation sets it again.\n */\nexport async function recordLocaleSave(input: LocaleSaveInput): Promise<void> {\n const { entityName, entityId } = input\n const app = appOrNone()\n if (!app) return\n try {\n const entity = app.entities.get(entityName)\n if (!entity || !hasTranslatableText(entity)) return\n const defaultLocale = getContentConfig().defaultLocale\n const locale = realLocale(input.locale, defaultLocale)\n const read = await readWorkingData(\n entity,\n entityId,\n locale,\n defaultLocale,\n input.draftData === undefined ? undefined : { ...input.draftData },\n )\n if (!read) return\n const contentHash = documentTextHash(entity, read.working)\n const client = provenanceClient()\n const now = new Date()\n const key = { entityType: entity.name, entityId }\n\n if (\n input.contentOnly === true ||\n (locale === defaultLocale && input.sourceLocale === undefined)\n ) {\n // A hash-only refresh and a discard author nothing — no user is recorded.\n const author =\n input.contentOnly === true || input.reverted === true ? undefined : input.userId\n await upsertContent(client, { ...key, locale }, contentHash, now, author)\n return\n }\n\n const sourceLocale = realLocale(input.sourceLocale ?? defaultLocale, defaultLocale)\n if (sourceLocale === locale) return\n let sourceHash = input.sourceHash\n if (sourceHash === undefined) {\n // The source's own row is the comparison target, so make sure it exists:\n // a document last saved before this table did has none.\n const source = await client.findOne({ ...key, locale: sourceLocale })\n if (source) {\n sourceHash = String(source.contentHash)\n } else {\n const sourceData = await readWorkingData(entity, entityId, sourceLocale, defaultLocale)\n if (!sourceData) return\n sourceHash = documentTextHash(entity, sourceData.working)\n await upsertContent(client, { ...key, locale: sourceLocale }, sourceHash, now, undefined)\n }\n }\n\n const existing = await client.findOne({ ...key, locale })\n // Did this save change the locale's TEXT? Against the recorded hash when\n // there is one. A locale with nothing recorded (every document saved before\n // this table existed, and every brand-new translation) has no hash to\n // compare with, so:\n // - with a DRAFT over the row, the row is the \"before\": a draft whose text\n // equals the row's changed no words — an agent swapping an image in a\n // hand-translated page has not machine-translated it;\n // - with NO draft, the write landed on the row itself and there is no\n // \"before\" left to read. Then the question is whether the locale holds a\n // translation at all (its text differs from the source's). That errs\n // toward `ai` for an agent's first write to an old hand translation —\n // a badge a person clears with \"Mark reviewed\" — and never toward\n // recording machine text as human, which is the failure that matters.\n const changed =\n existing !== null\n ? existing.contentHash !== contentHash\n : read.drafted\n ? documentTextHash(entity, read.row) !== contentHash\n : contentHash !== sourceHash\n // Machine output is: what the engine says it wrote, or TEXT that arrived\n // through a token-derived door. The second needs the text to have changed —\n // an agent publishing, or re-saving the same words, has written nothing,\n // and must not turn a reviewed translation back into an unreviewed one. A\n // discard writes nothing either.\n const machine =\n input.method === 'ai' ||\n (changed && input.reverted !== true && isTokenDerivedDoor(getContext()?.door))\n const authored =\n input.userId !== undefined &&\n input.reverted !== true &&\n (existing === null || existing.contentHash !== contentHash)\n const wasMachine = existing?.method === 'ai'\n const method: TranslationMethod =\n machine || wasMachine ? 'ai' : contentHash === sourceHash ? 'copy' : 'human'\n await client.upsert(\n {\n ...key,\n locale,\n contentHash,\n contentChangedAt: now,\n sourceLocale,\n sourceHash,\n method,\n reviewedAt: null,\n reviewedBy: null,\n updatedAt: now,\n updatedBy: input.userId ?? null,\n },\n {\n target: ['entityType', 'entityId', 'locale'],\n set: {\n contentHash,\n ...((existing === null || existing.contentHash !== contentHash) && {\n contentChangedAt: now,\n }),\n sourceLocale,\n sourceHash,\n method,\n // Fresh machine output is unreviewed again; anything else keeps the\n // review it had.\n ...(machine && { reviewedAt: null, reviewedBy: null }),\n updatedAt: now,\n // WHO LAST WROTE THE TEXT — what \"an agent may not review its own\n // translation\" is checked against. So it moves only when this save\n // changed the words AND says who did: a discard, a publish or a\n // recording with no known user wrote nothing, and letting one of\n // them blank it would let an agent launder its own authorship\n // (write, stage a draft, discard it, then review).\n ...(authored && { updatedBy: input.userId }),\n },\n },\n )\n } catch (error) {\n // Deliberately swallowed — see the module docblock (\"never fails a save\").\n app.logger.warn(\n { err: error, entity: entityName, entityId, locale: input.locale },\n 'translation provenance: could not record a locale save',\n )\n }\n}\n\nasync function upsertContent(\n client: ReturnType<typeof provenanceClient>,\n key: { entityType: string; entityId: string; locale: string },\n contentHash: string,\n now: Date,\n userId: string | undefined,\n): Promise<void> {\n const existing = await client.findOne(key)\n if (existing && existing.contentHash === contentHash) return\n await client.upsert(\n {\n ...key,\n contentHash,\n contentChangedAt: now,\n updatedAt: now,\n updatedBy: userId ?? null,\n },\n {\n target: ['entityType', 'entityId', 'locale'],\n // The author moves only when one is known — see `authored` above.\n set: {\n contentHash,\n contentChangedAt: now,\n updatedAt: now,\n ...(userId !== undefined && { updatedBy: userId }),\n },\n },\n )\n}\n\n/**\n * Mark a translation reviewed. The caller checks permission.\n *\n * - `'none'` — nothing is recorded for that locale; there is nothing to review.\n * - `'self'` — `refuseSelf` was set and the reviewer is the one who last wrote\n * the text. A review is someone ELSE vouching for it: an agent that could\n * clear \"not reviewed\" on its own output would make the badge say nothing\n * (the approval queue refuses the asking principal for the same reason).\n */\nexport async function markTranslationReviewed(input: {\n entityType: string\n entityId: string\n locale: string\n userId: string\n refuseSelf?: boolean | undefined\n}): Promise<'reviewed' | 'none' | 'self'> {\n const client = provenanceClient()\n const key = { entityType: input.entityType, entityId: input.entityId, locale: input.locale }\n const existing = await client.findOne(key)\n if (!existing) return 'none'\n if (input.refuseSelf === true && existing.updatedBy === input.userId) return 'self'\n const now = new Date()\n // `updatedBy` is left alone: it is who last WROTE the text.\n await client.update(key, { reviewedAt: now, reviewedBy: input.userId, updatedAt: now })\n return 'reviewed'\n}\n\n/** Every stored provenance row for a set of documents of one entity type — ONE query. */\nexport async function getTranslationProvenanceBatch(\n entityType: string,\n entityIds: readonly string[],\n db?: Db,\n): Promise<Map<string, Map<string, TranslationProvenance>>> {\n const out = new Map<string, Map<string, TranslationProvenance>>()\n if (entityIds.length === 0) return out\n const rows = await provenanceClient(db).findMany({\n where: { entityType: { eq: entityType }, entityId: { in: [...entityIds] } },\n })\n for (const row of rows) {\n const id = String(row.entityId)\n let byLocale = out.get(id)\n if (!byLocale) {\n byLocale = new Map()\n out.set(id, byLocale)\n }\n const provenance = toProvenance(row as Row)\n byLocale.set(provenance.locale, provenance)\n }\n return out\n}\n\n/** What a translation's provenance says about it, against its source's. */\nexport interface TranslationFreshness {\n method: TranslationMethod | null\n sourceLocale: string | null\n /** The source text has changed since this translation was saved. */\n outdated: boolean\n /** When the source text changed — ISO, only when {@link outdated}. */\n outdatedSince: string | null\n /** Machine output nobody has marked reviewed. */\n machineUnreviewed: boolean\n reviewedAt: string | null\n}\n\n/** Compare one locale's provenance with its source's. `null` when nothing is recorded. */\nexport function translationFreshness(\n byLocale: ReadonlyMap<string, TranslationProvenance> | undefined,\n locale: string,\n): TranslationFreshness | null {\n const own = byLocale?.get(locale)\n if (!own) return null\n const source = own.sourceLocale !== null ? byLocale?.get(own.sourceLocale) : undefined\n const outdated =\n source !== undefined && own.sourceHash !== null && source.contentHash !== own.sourceHash\n return {\n method: own.method,\n sourceLocale: own.sourceLocale,\n outdated,\n outdatedSince: outdated && source ? new Date(source.contentChangedAt).toISOString() : null,\n machineUnreviewed: own.method === 'ai' && own.reviewedAt === null,\n reviewedAt: own.reviewedAt ? new Date(own.reviewedAt).toISOString() : null,\n }\n}\n\n// ---------------------------------------------------------------------------\n// The queue — recordings that must not be awaited by the write that caused them\n// ---------------------------------------------------------------------------\n\n/**\n * A recording waiting to run. `run` is bound to the async context it was\n * QUEUED in (`AsyncResource.bind`): the queue is drained from the completion of\n * whichever recording finished last, and without the binding a queued recording\n * would run under THAT request's context — another caller's door, user and\n * site (review, PR10).\n */\ninterface Pending {\n run: () => Promise<void>\n}\n\n/** Keyed so a burst of writes to one document and locale collapses to the last. */\nconst pending = new Map<string, Pending>()\n/** Recordings running now, by key — one document and locale is never recorded twice at once. */\nconst inFlight = new Map<string, Promise<void>>()\n\n/** Recordings in flight at once — far below any connection pool. */\nconst MAX_CONCURRENT = 2\n/**\n * Beyond this many waiting, new ones are dropped (and logged): a bulk import\n * must not queue without end, and each entry may hold a draft's data.\n */\nconst MAX_PENDING = 200\n\nfunction drain(): void {\n for (const [key, entry] of pending) {\n if (inFlight.size >= MAX_CONCURRENT) return\n // The same document and locale is already being recorded: this one waits\n // for it. Started together, the OLDER one could finish last and leave its\n // hash behind — two quick edits of one page are ordinary.\n if (inFlight.has(key)) continue\n pending.delete(key)\n const done = entry.run().finally(() => {\n inFlight.delete(key)\n drain()\n })\n inFlight.set(key, done)\n }\n}\n\n/**\n * Record a locale save WITHOUT being awaited by it: bounded, coalesced per\n * document and locale, and run under the caller's own request context.\n *\n * For a writer that holds a transaction (the block editor's draft write inside\n * op-commit's): awaiting a recording there would take a SECOND pool connection\n * while holding the first, and enough concurrent commits would each wait for a\n * connection none of them can release.\n */\nexport function enqueueLocaleSave(input: LocaleSaveInput): void {\n const app = appOrNone()\n if (!app) return\n const key = queueKey(input.entityName, input.entityId, input.locale)\n if (!pending.has(key) && pending.size >= MAX_PENDING) {\n app.logger.warn(\n {\n entity: input.entityName,\n entityId: input.entityId,\n locale: input.locale,\n waiting: pending.size,\n },\n 'translation provenance: too many recordings waiting — this save was not recorded',\n )\n return\n }\n pending.set(key, { run: AsyncResource.bind(() => recordLocaleSave(input)) })\n drain()\n}\n\n/**\n * A write that landed on the live row — the `'entity.writeListeners'`\n * contribution. Synchronous and `void`, per the slot's contract.\n *\n * A `delete` is NOT recorded and removes nothing: the event does not say\n * whether the row was trashed or purged, and a trashed document that is\n * restored must come back with what it was translated from and whether that\n * was reviewed. A purged document's rows are a few hundred bytes addressed by\n * a uuid that is never reused.\n */\nexport const translationProvenanceWriteListener: EntityWriteObserver = (event) => {\n if (event.action === 'delete') return\n const app = appOrNone()\n if (!app) return\n const entity = app.entities.get(event.entityName)\n if (!entity || !hasTranslatableText(entity)) return\n enqueueLocaleSave({\n entityName: event.entityName,\n entityId: event.id,\n locale: event.locale ?? DEFAULT_LOCALE_SENTINEL,\n userId: getContext()?.user?.id,\n })\n}\n\nfunction queueKey(entityName: string, entityId: string, locale: string): string {\n return `${entityName}:${entityId}:${locale}`\n}\n\n/**\n * Resolves once the queued recordings for ONE document and locale have run —\n * for a caller about to write that row itself, which must come last. Bounded:\n * it waits for that key only, never the whole queue, and gives up after\n * `maxMs` rather than hold a request on a recording that will not settle.\n */\nexport async function localeSaveSettled(\n entityName: string,\n entityId: string,\n locale: string,\n maxMs = 3000,\n): Promise<void> {\n const key = queueKey(entityName, entityId, locale)\n const deadline = Date.now() + maxMs\n while (Date.now() < deadline) {\n const running = inFlight.get(key)\n if (running !== undefined) {\n await Promise.race([running, new Promise((resolve) => setTimeout(resolve, 50))])\n continue\n }\n if (!pending.has(key)) return\n await new Promise((resolve) => setTimeout(resolve, 5))\n }\n}\n\n/** Resolves once every queued recording has run. For tests; a request uses {@link localeSaveSettled}. */\nexport async function provenanceQueueIdle(): Promise<void> {\n while (inFlight.size > 0 || pending.size > 0)\n await new Promise((resolve) => setTimeout(resolve, 5))\n}\n"],"mappings":"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"}
@@ -104,7 +104,7 @@ declare const translationProvenanceTable: {
104
104
  };
105
105
  dialect: "pg";
106
106
  }>>;
107
- partitionColumn: "id" | "updatedAt" | "updatedBy" | "locale" | "entityType" | "entityId" | "contentHash" | "contentChangedAt" | "sourceLocale" | "sourceHash" | "method" | "reviewedAt" | "reviewedBy" | undefined;
107
+ partitionColumn: "entityId" | "locale" | "id" | "updatedAt" | "entityType" | "contentHash" | "contentChangedAt" | "sourceLocale" | "sourceHash" | "method" | "reviewedAt" | "reviewedBy" | "updatedBy" | undefined;
108
108
  };
109
109
  //#endregion
110
110
  //#region src/translation-provenance.d.ts
@@ -250,4 +250,4 @@ declare function localeSaveSettled(entityName: string, entityId: string, locale:
250
250
  declare function provenanceQueueIdle(): Promise<void>;
251
251
  //#endregion
252
252
  export { documentTextHash as a, hasTranslatableText as c, provenanceQueueIdle as d, recordLocaleSave as f, translationFreshness as g, translatableTreeFields as h, TranslationProvenance as i, localeSaveSettled as l, translatableTextFields as m, TranslationFreshness as n, enqueueLocaleSave as o, translatableFieldTexts as p, TranslationMethod as r, getTranslationProvenanceBatch as s, LocaleSaveInput as t, markTranslationReviewed as u };
253
- //# sourceMappingURL=translation-provenance--dHtcmfg.d.mts.map
253
+ //# sourceMappingURL=translation-provenance-BZ0MlbFR.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"translation-provenance--dHtcmfg.d.mts","names":[],"sources":["../src/schema/translation-provenance.ts","../src/translation-provenance.ts"],"mappings":";;;;;;AA2BA;;;;;;;;;;;;;;;;;;;;cAAa,0BAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KCsBD,iBAAA;;UAGK,qBAAA;EACf,MAAA;EACA,WAAA;EACA,gBAAA,EAAkB,IAAA;EAClB,YAAA;EACA,UAAA;EACA,MAAA,EAAQ,iBAAA;EACR,UAAA,EAAY,IAAA;EACZ,UAAA;AAAA;AAAA,KAGG,GAAA,GAAM,MAAM;;;;;;iBAYD,sBAAA,CAAuB,MAAA,EAAQ,IAAI,CAAC,MAAA;;iBAUpC,sBAAA,CAAuB,MAAA,EAAQ,IAAI,CAAC,MAAA;;;;;;;iBAapC,sBAAA,CACd,MAAA,EAAQ,IAAA,CAAK,MAAA,gBACb,IAAA,EAAM,QAAA,CAAS,MAAA;AAAA,iBAUD,mBAAA,CAAoB,MAAA,EAAQ,IAAI,CAAC,MAAA;;;;;iBAwBjC,gBAAA,CAAiB,MAAA,EAAQ,IAAA,CAAK,MAAA,gBAAsB,IAAA,EAAM,GAAA;AAAA,KA4BrE,EAAA,GAAK,UAAU,QAAQ,0BAAA,CAA2B,UAAA;;UA8DtC,eAAA;;EAEf,UAAA;EACA,QAAA;;EAEA,MAAA;;EAEA,MAAA;;;;;;EAMA,SAAA,GAAY,QAAQ,CAAC,MAAA;;;;;;;EAOrB,MAAA;;;;;;EAMA,QAAA;;;;;;;;EAQA,WAAA;;EAEA,YAAA;;;;;EAKA,UAAA;AAAA;;;;;;;;;iBAWoB,gBAAA,CAAiB,KAAA,EAAO,eAAA,GAAkB,OAAO;;;;;;;;;;iBA2KjD,uBAAA,CAAwB,KAAA;EAC5C,UAAA;EACA,QAAA;EACA,MAAA;EACA,MAAA;EACA,UAAA;AAAA,IACE,OAAO;AApZkB;AAAA,iBAiaP,6BAAA,CACpB,UAAA,UACA,SAAA,qBACA,EAAA,GAAK,EAAA,GACJ,OAAA,CAAQ,GAAA,SAAY,GAAA,SAAY,qBAAA;;UAoBlB,oBAAA;EACf,MAAA,EAAQ,iBAAiB;EACzB,YAAA;EAjbY;EAmbZ,QAAA;EAnbgB;EAqbhB,aAAA;EA1bA;EA4bA,iBAAA;EACA,UAAA;AAAA;;iBAIc,oBAAA,CACd,QAAA,EAAU,WAAA,SAAoB,qBAAA,eAC9B,MAAA,WACC,oBAAA;;;;;;AA9bS;AACX;;;iBAkgBe,iBAAA,CAAkB,KAAsB,EAAf,eAAe;;;;;;;iBAsDlC,iBAAA,CACpB,UAAA,UACA,QAAA,UACA,MAAA,UACA,KAAA,YACC,OAAO;AAriB8D;AAAA,iBAojBlD,mBAAA,CAAA,GAAuB,OAAO"}
1
+ {"version":3,"file":"translation-provenance-BZ0MlbFR.d.mts","names":[],"sources":["../src/schema/translation-provenance.ts","../src/translation-provenance.ts"],"mappings":";;;;;;AA2BA;;;;;;;;;;;;;;;;;;;;cAAa,0BAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KCsBD,iBAAA;;UAGK,qBAAA;EACf,MAAA;EACA,WAAA;EACA,gBAAA,EAAkB,IAAA;EAClB,YAAA;EACA,UAAA;EACA,MAAA,EAAQ,iBAAA;EACR,UAAA,EAAY,IAAA;EACZ,UAAA;AAAA;AAAA,KAGG,GAAA,GAAM,MAAM;;;;;;iBAYD,sBAAA,CAAuB,MAAA,EAAQ,IAAI,CAAC,MAAA;;iBAUpC,sBAAA,CAAuB,MAAA,EAAQ,IAAI,CAAC,MAAA;;;;;;;iBAapC,sBAAA,CACd,MAAA,EAAQ,IAAA,CAAK,MAAA,gBACb,IAAA,EAAM,QAAA,CAAS,MAAA;AAAA,iBAUD,mBAAA,CAAoB,MAAA,EAAQ,IAAI,CAAC,MAAA;;;;;iBAwBjC,gBAAA,CAAiB,MAAA,EAAQ,IAAA,CAAK,MAAA,gBAAsB,IAAA,EAAM,GAAA;AAAA,KA4BrE,EAAA,GAAK,UAAU,QAAQ,0BAAA,CAA2B,UAAA;;UA8DtC,eAAA;;EAEf,UAAA;EACA,QAAA;;EAEA,MAAA;;EAEA,MAAA;;;;;;EAMA,SAAA,GAAY,QAAQ,CAAC,MAAA;;;;;;;EAOrB,MAAA;;;;;;EAMA,QAAA;;;;;;;;EAQA,WAAA;;EAEA,YAAA;;;;;EAKA,UAAA;AAAA;;;;;;;;;iBAWoB,gBAAA,CAAiB,KAAA,EAAO,eAAA,GAAkB,OAAO;;;;;;;;;;iBA2KjD,uBAAA,CAAwB,KAAA;EAC5C,UAAA;EACA,QAAA;EACA,MAAA;EACA,MAAA;EACA,UAAA;AAAA,IACE,OAAO;AApZkB;AAAA,iBAiaP,6BAAA,CACpB,UAAA,UACA,SAAA,qBACA,EAAA,GAAK,EAAA,GACJ,OAAA,CAAQ,GAAA,SAAY,GAAA,SAAY,qBAAA;;UAoBlB,oBAAA;EACf,MAAA,EAAQ,iBAAiB;EACzB,YAAA;EAjbY;EAmbZ,QAAA;EAnbgB;EAqbhB,aAAA;EA1bA;EA4bA,iBAAA;EACA,UAAA;AAAA;;iBAIc,oBAAA,CACd,QAAA,EAAU,WAAA,SAAoB,qBAAA,eAC9B,MAAA,WACC,oBAAA;;;;;;AA9bS;AACX;;;iBAkgBe,iBAAA,CAAkB,KAAsB,EAAf,eAAe;;;;;;;iBAsDlC,iBAAA,CACpB,UAAA,UACA,QAAA,UACA,MAAA,UACA,KAAA,YACC,OAAO;AAriB8D;AAAA,iBAojBlD,mBAAA,CAAA,GAAuB,OAAO"}
@@ -1,2 +1,144 @@
1
- import { a as documentTextHash, c as hasTranslatableText, d as provenanceQueueIdle, f as recordLocaleSave, g as translationFreshness, h as translatableTreeFields, i as TranslationProvenance, l as localeSaveSettled, m as translatableTextFields, n as TranslationFreshness, o as enqueueLocaleSave, p as translatableFieldTexts, r as TranslationMethod, s as getTranslationProvenanceBatch, t as LocaleSaveInput, u as markTranslationReviewed } from "./translation-provenance--dHtcmfg.mjs";
2
- export { type LocaleSaveInput, type TranslationFreshness, type TranslationMethod, type TranslationProvenance, documentTextHash, enqueueLocaleSave, getTranslationProvenanceBatch, hasTranslatableText, localeSaveSettled, markTranslationReviewed, provenanceQueueIdle, recordLocaleSave, translatableFieldTexts, translatableTextFields, translatableTreeFields, translationFreshness };
1
+ import { a as documentTextHash, c as hasTranslatableText, d as provenanceQueueIdle, f as recordLocaleSave, g as translationFreshness, h as translatableTreeFields, i as TranslationProvenance, l as localeSaveSettled, m as translatableTextFields, n as TranslationFreshness, o as enqueueLocaleSave, p as translatableFieldTexts, r as TranslationMethod, s as getTranslationProvenanceBatch, t as LocaleSaveInput, u as markTranslationReviewed } from "./translation-provenance-BZ0MlbFR.mjs";
2
+
3
+ //#region src/translatable-sources.d.ts
4
+ /**
5
+ * `'content.translatableSources'` — text OUTSIDE the entity translation tables
6
+ * that a whole-site translation still has to cover (agent-access D019).
7
+ *
8
+ * A menu keeps its per-locale labels inside one JSON tree, so the translation
9
+ * engine's walk over translatable entity fields never sees them. Content owns
10
+ * this slot so that the engine (`@murumets-ee/translation`) and the plugin that
11
+ * owns such text (`@murumets-ee/menus`) need no dependency on each other: the
12
+ * owner DECLARES a {@link TranslatableSource} on its plugin's `contributes`,
13
+ * and the engine reads them with {@link getTranslatableSources}.
14
+ *
15
+ * No `server-only` and no database import: this module is types plus one
16
+ * registry read, so a plugin entry that jiti loads may import from it.
17
+ */
18
+ /** One piece of source-locale text a {@link TranslatableSource} wants translated. */
19
+ interface TranslatableSourceText {
20
+ /** Stable within one item — the job hands it back to `write`. */
21
+ key: string;
22
+ text: string;
23
+ maxLength?: number;
24
+ }
25
+ /** One translatable thing of a kind — a menu, say — as `list` reports it. */
26
+ interface TranslatableSourceItem {
27
+ id: string;
28
+ label: string;
29
+ }
30
+ interface TranslatableSourceRead {
31
+ /** What `texts` was read from. `write` refuses (`'changed'`) when the item no longer matches it. */
32
+ revision: string;
33
+ /** The source-locale texts that need a translation for this target, per `mode`. Empty = nothing to do. */
34
+ texts: TranslatableSourceText[];
35
+ }
36
+ /**
37
+ * Text outside the entity translation tables that a whole-site translation
38
+ * covers (agent-access D019).
39
+ *
40
+ * ⚠️ **Every method runs INSIDE the caller's request context.** `getApp()` /
41
+ * `app.getClient(entity)` see the ambient user and permission checker, and that
42
+ * is the only authority a source has: it does its reads and writes through
43
+ * `AdminClient` / `ContentClient` and NEVER elevates — no
44
+ * `createSystemAdminClient`, no `runAsCli`, no hand-built context. The engine
45
+ * checks {@link TranslatableSource.permission} with the starter's own checker
46
+ * before it lists anything; the entity firewall under these methods is the
47
+ * second gate, not a formality.
48
+ *
49
+ * Each call touches ONE item. `list` is the only enumeration, and it is a
50
+ * bounded page.
51
+ */
52
+ interface TranslatableSource {
53
+ /** Unique: `'menu'`. Lower-case `[a-z][a-z0-9_]*`. */
54
+ kind: string;
55
+ /** English fallback beside {@link labelKey} (CLAUDE.md "Interface copy") — never rendered when the key resolves. */
56
+ label: string;
57
+ /** next-intl key for {@link label}, in the contributing plugin's own namespace. */
58
+ labelKey: string;
59
+ /** What the caller must hold to translate this kind. The job checks it with the starter's own checker before listing. */
60
+ permission: {
61
+ resource: string;
62
+ action: string;
63
+ };
64
+ /** `true`: `write` lands in a draft and nothing is live until someone publishes. */
65
+ draft: boolean;
66
+ /**
67
+ * A bounded keyset page, ordered by id: the items after `after`, at most
68
+ * `limit` of them. `limit` is never above 200, and a source must honour it
69
+ * EXACTLY up to that: the job asks for one item more than it reads, and
70
+ * takes a page of FEWER rows than it asked for to mean "there are no more
71
+ * rows". A source that caps its page lower than the `limit` it was given
72
+ * silently ends the scan there — the rest is never planned, and nothing
73
+ * says so.
74
+ *
75
+ * Under scope `all` a source's items are taken by EVERY run: a source
76
+ * records nothing that says "this is already a current machine
77
+ * translation" (`read` with mode `'all'` returns every text that has a
78
+ * source). Only scope `missing` comes to an end for a source.
79
+ */
80
+ list(input: {
81
+ after?: string;
82
+ limit: number;
83
+ }): Promise<TranslatableSourceItem[]>;
84
+ /**
85
+ * `null` = no such item (or not readable by this caller). mode `'missing'`:
86
+ * only texts the target does not have yet; `'all'`: every text that has a
87
+ * source.
88
+ */
89
+ read(input: {
90
+ id: string;
91
+ sourceLocale: string;
92
+ targetLocale: string;
93
+ mode: 'missing' | 'all';
94
+ }): Promise<TranslatableSourceRead | null>;
95
+ write(input: {
96
+ id: string;
97
+ targetLocale: string;
98
+ revision: string; /** key → translated text, for keys `read` returned. A key not in `texts` is left alone. */
99
+ texts: Readonly<Record<string, string>>;
100
+ user: {
101
+ id: string;
102
+ name?: string;
103
+ };
104
+ }): Promise<'written' | 'changed' | 'not_found'>;
105
+ }
106
+ declare module '@murumets-ee/core' {
107
+ interface PluginContributionSlots {
108
+ /**
109
+ * Translatable text that lives outside the entity translation tables, one
110
+ * list per contributing plugin. Read it with `getTranslatableSources()`
111
+ * from `@murumets-ee/content/translation`, which flattens the lists.
112
+ */
113
+ 'content.translatableSources': readonly TranslatableSource[];
114
+ /**
115
+ * Where a person starts a whole-site translation run — the admin page, and
116
+ * the permission it needs. Contributed by `@murumets-ee/translation`; read
117
+ * with `getSiteTranslationEntry()` by the editor, which links to it.
118
+ */
119
+ 'content.siteTranslation': SiteTranslationEntry;
120
+ }
121
+ }
122
+ /**
123
+ * Every contributed {@link TranslatableSource}, as one flat list.
124
+ *
125
+ * One entry per `kind`: when two plugins contribute the same kind the LATER
126
+ * plugin (in `plugins:` order) wins and keeps the earlier one's position, so
127
+ * an app can replace a source without the list reordering. `[]` when nothing
128
+ * contributes — an app without menus simply has no extra text to translate.
129
+ */
130
+ declare function getTranslatableSources(): TranslatableSource[];
131
+ /** The whole-site translation page, as the content editor links to it. */
132
+ interface SiteTranslationEntry {
133
+ /** An admin path without the locale prefix, e.g. `/admin/translate-site`. */
134
+ href: string;
135
+ permission: {
136
+ resource: string;
137
+ action: string;
138
+ };
139
+ }
140
+ /** The contributed whole-site translation page, or `undefined` when no plugin offers one. */
141
+ declare function getSiteTranslationEntry(): SiteTranslationEntry | undefined;
142
+ //#endregion
143
+ export { type LocaleSaveInput, type SiteTranslationEntry, type TranslatableSource, type TranslatableSourceItem, type TranslatableSourceRead, type TranslatableSourceText, type TranslationFreshness, type TranslationMethod, type TranslationProvenance, documentTextHash, enqueueLocaleSave, getSiteTranslationEntry, getTranslatableSources, getTranslationProvenanceBatch, hasTranslatableText, localeSaveSettled, markTranslationReviewed, provenanceQueueIdle, recordLocaleSave, translatableFieldTexts, translatableTextFields, translatableTreeFields, translationFreshness };
144
+ //# sourceMappingURL=translation.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translation.d.mts","names":[],"sources":["../src/translatable-sources.ts"],"mappings":";;;;;;AAkBA;;;;;;;;AAIW;AAIX;;;UARiB,sBAAA;EAUV;EARL,GAAA;EACA,IAAA;EACA,SAAA;AAAA;;UAIe,sBAAA;EACf,EAAA;EACA,KAAK;AAAA;AAAA,UAGU,sBAAA;EAuBkB;EArBjC,QAAA;EA8CwD;EA5CxD,KAAA,EAAO,sBAAsB;AAAA;;;;;;;;;;;;;;;;;UAmBd,kBAAA;EAyBiC;EAvBhD,IAAA;EA6BA;EA3BA,KAAA;EA6BE;EA3BF,QAAA;EA6BE;EA3BF,UAAA;IAAc,QAAA;IAAkB,MAAA;EAAA;EA8B9B;EA5BF,KAAA;EA8BE;;;;;;;;;;AAIS;AACZ;;;EApBC,IAAA,CAAK,KAAA;IAAS,KAAA;IAAgB,KAAA;EAAA,IAAkB,OAAA,CAAQ,sBAAA;EAmCtD;;;;AAA+C;EA7BjD,IAAA,CAAK,KAAA;IACH,EAAA;IACA,YAAA;IACA,YAAA;IACA,IAAA;EAAA,IACE,OAAA,CAAQ,sBAAA;EACZ,KAAA,CAAM,KAAA;IACJ,EAAA;IACA,YAAA;IACA,QAAA,UA4CF;IA1CE,KAAA,EAAO,QAAA,CAAS,MAAA;IAChB,IAAA;MAAQ,EAAA;MAAY,IAAA;IAAA;EAAA,IAClB,OAAA;AAAA;AAAA;EAAA,UAIM,uBAAA;;;;;;IAMR,6BAAA,WAAwC,kBAAA;;;;;;IAMxC,yBAAA,EAA2B,oBAAoB;EAAA;AAAA;;;;;;;;;iBAYnC,sBAAA,CAAA,GAA0B,kBAAkB;;UAS3C,oBAAA;;EAEf,IAAA;EACA,UAAA;IAAc,QAAA;IAAkB,MAAA;EAAA;AAAA;;iBAIlB,uBAAA,CAAA,GAA2B,oBAAoB"}
@@ -1 +1,2 @@
1
- import{a as e,c as t,d as n,f as r,i,l as a,n as o,o as s,r as c,s as l,t as u,u as d}from"./translation-provenance-eqhsGAyi.mjs";export{u as documentTextHash,o as enqueueLocaleSave,c as getTranslationProvenanceBatch,i as hasTranslatableText,e as localeSaveSettled,s as markTranslationReviewed,l as provenanceQueueIdle,t as recordLocaleSave,a as translatableFieldTexts,d as translatableTextFields,n as translatableTreeFields,r as translationFreshness};
1
+ import{a as e,c as t,d as n,f as r,i,l as a,n as o,o as s,r as c,s as l,t as u,u as d}from"./translation-provenance-B41lEDM-.mjs";import{getContributions as f}from"@murumets-ee/core";function p(){let e=new Map;for(let t of f(`content.translatableSources`))for(let n of t)e.set(n.kind,n);return[...e.values()]}function m(){return f(`content.siteTranslation`).at(-1)}export{u as documentTextHash,o as enqueueLocaleSave,m as getSiteTranslationEntry,p as getTranslatableSources,c as getTranslationProvenanceBatch,i as hasTranslatableText,e as localeSaveSettled,s as markTranslationReviewed,l as provenanceQueueIdle,t as recordLocaleSave,a as translatableFieldTexts,d as translatableTextFields,n as translatableTreeFields,r as translationFreshness};
2
+ //# sourceMappingURL=translation.mjs.map