@murumets-ee/content 0.75.1 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/admin.mjs +1 -1
  2. package/dist/block-resolver.d.mts +5 -0
  3. package/dist/block-resolver.d.mts.map +1 -1
  4. package/dist/block-resolver.mjs +1 -1
  5. package/dist/block-resolver.mjs.map +1 -1
  6. package/dist/client.mjs +1 -1
  7. package/dist/enumerate-CBqu63Kp.mjs +2 -0
  8. package/dist/{enumerate-B5tWe9JC.mjs.map → enumerate-CBqu63Kp.mjs.map} +1 -1
  9. package/dist/plugin-BxOHoKPR.mjs +2 -0
  10. package/dist/{plugin-DEYaEeWC.mjs.map → plugin-BxOHoKPR.mjs.map} +1 -1
  11. package/dist/plugin.mjs +1 -1
  12. package/dist/resolve-context-MRZZ3rQl.mjs +2 -0
  13. package/dist/{resolve-context-rr-Eanwp.mjs.map → resolve-context-MRZZ3rQl.mjs.map} +1 -1
  14. package/dist/resolve.mjs +2 -2
  15. package/dist/resolve.mjs.map +1 -1
  16. package/dist/routes-C9I1iZw8.mjs +2 -0
  17. package/dist/{routes-DuJa-w4T.mjs.map → routes-C9I1iZw8.mjs.map} +1 -1
  18. package/dist/schema-0zayzpD_.mjs +2 -0
  19. package/dist/schema-0zayzpD_.mjs.map +1 -0
  20. package/dist/schema-D8sBQozw.mjs +2 -0
  21. package/dist/schema-D8sBQozw.mjs.map +1 -0
  22. package/dist/seed.d.mts +20 -1
  23. package/dist/seed.d.mts.map +1 -1
  24. package/dist/seed.mjs +1 -1
  25. package/dist/seed.mjs.map +1 -1
  26. package/dist/{versions-qld3n7B-.mjs → versions-BDxB6k_S.mjs} +2 -2
  27. package/dist/{versions-qld3n7B-.mjs.map → versions-BDxB6k_S.mjs.map} +1 -1
  28. package/package.json +9 -9
  29. package/dist/apply-path-s-YeCUXP.mjs +0 -2
  30. package/dist/apply-path-s-YeCUXP.mjs.map +0 -1
  31. package/dist/enumerate-B5tWe9JC.mjs +0 -2
  32. package/dist/plugin-DEYaEeWC.mjs +0 -2
  33. package/dist/resolve-context-rr-Eanwp.mjs +0 -2
  34. package/dist/routes-DuJa-w4T.mjs +0 -2
  35. package/dist/schema-BPDn8ajz.mjs +0 -2
  36. package/dist/schema-BPDn8ajz.mjs.map +0 -1
package/dist/seed.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{n as t}from"./apply-path-s-YeCUXP.mjs";import{n,t as r}from"./site-chrome-BhlkbRuq.mjs";import{getApp as i}from"@murumets-ee/core";import{eq as a}from"drizzle-orm";import{createAdminClient as o}from"@murumets-ee/core/clients";import{randomUUID as s}from"node:crypto";const c={header:{handle:`main`},footer:{handle:`footer`}};function l(e){return[{id:s(),type:`menu`,props:{handle:c[e].handle,orientation:`horizontal`},traits:{}}]}async function u(){let s=i().entities.get(e);if(!s)return{created:0};let c=o(s),u=0,d=c.getTable().placement;if(!d)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of r)if(!((await c.findMany({where:a(d,e),limit:1})).length>0))try{await c.create({placement:e,label:n[e],layout:l(e)}),u++}catch(e){if(!t(e))throw e}return{created:u}}export{u as seedSiteChrome};
1
+ import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{i as t,n}from"./schema-D8sBQozw.mjs";import{n as r,t as i}from"./site-chrome-BhlkbRuq.mjs";import{getApp as a}from"@murumets-ee/core";import{eq as o}from"drizzle-orm";import{createAdminClient as s}from"@murumets-ee/core/clients";import{randomUUID as c}from"node:crypto";const l={header:{handle:`main`},footer:{handle:`footer`}};function u(e){return[{id:c(),type:`menu`,props:{handle:l[e].handle,orientation:`horizontal`},traits:{}}]}async function d(){let n=a().entities.get(e);if(!n)return{created:0};let c=s(n),l=0,d=c.getTable().placement;if(!d)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of i)if(!((await c.findMany({where:o(d,e),limit:1})).length>0))try{await c.create({placement:e,label:r[e],layout:u(e)}),l++}catch(e){if(!t(e))throw e}return{created:l}}async function f(e){let t=n.makeClient(a().db.readWrite),r={};for(let n of e)r[n]=(await t.deleteMany({entityType:n})).length;return r}export{f as forgetEntityPaths,d as seedSiteChrome};
2
2
  //# sourceMappingURL=seed.mjs.map
package/dist/seed.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The site-chrome seeder — creates the one `site_chrome` row per placement\n * that a site needs, POPULATED, before any of them can be edited.\n *\n * Contributed as `content:site-chrome` from the `content()` plugin factory and\n * reached through the `@murumets-ee/content/seed` subpath — its own build\n * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries\n * `import 'server-only'`, off the config-load path.\n *\n * ## Why a seeder at all, and why nobody has to run it\n *\n * An unseeded chrome document is indistinguishable from a site that HAS no\n * header, and both the canvas and `/v1/_chrome` treat it that way — they\n * compose nothing and let the consumer render its own fallback. That is right,\n * and it means nothing ever CREATES the row, so without this an editor has no\n * header to open.\n *\n * Until PR30 that left the gap S5 names: nothing in the printed setup path ran\n * this, so a scaffolded project booted with an empty Site list and the only\n * bootstrap was a human pressing \"New\". The plugin now declares this seeder\n * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it\n * applies migrations — which is already step 4 of the printed next-steps. It\n * stays an ordinary named seeder too, runnable from `lumi seed` and the admin\n * Seed page; `bootstrap` adds a trigger rather than changing what it is.\n *\n * ## Why the entity is resolved by NAME\n *\n * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),\n * because an app must pass options it cannot get from here — `withOpLog()`\n * ships from `@murumets-ee/blocks/server`, and content must not depend on\n * blocks (blocks depends back on content). So there is no entity object in\n * this package to import; there is only the name the factory stamps on\n * whatever the app instantiated.\n *\n * That name is not an assumption: `site-chrome.ts` hardcodes\n * `name: 'site_chrome'`, so an app can configure the entity but cannot rename\n * it. This package's own chrome PROVIDER resolves it the same way\n * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s\n * default permission maps and `@murumets-ee/frontend-cache`'s\n * `DEFAULT_CHROME_ENTITIES` name the same string.\n *\n * ## Why it is `available`-gated rather than merely tolerant\n *\n * An app is free to register no chrome at all. Seeding plan F009 deferred this\n * seeder on exactly that: contributed unconditionally it would put a button on\n * the Seed page whose entire outcome is `created: 0`, which a reader takes as\n * \"this seeder is broken\", not as \"this project has no chrome\". The plugin\n * declares `available` so the button is ABSENT instead — the same contract\n * `rent:cars-demo` has, where absence follows from the plugin's absence rather\n * than from a silent no-op.\n *\n * ## Idempotent by construction, not by a flag\n *\n * Every run finds before it creates, keyed on `placement`. That is what makes\n * a second run a no-op — necessary now in a way it was not before, because\n * `lumi migrate` runs this on EVERY migrate, not once. It is also why the\n * find is a `where` on `placement` rather than a bare `limit: 1`: the old\n * `kind: 'singleton'` made \"is there a row\" the whole question, and under one\n * entity holding two rows it is the wrong question — a bare existence check\n * would see the header and never create the footer.\n *\n * ## DRAFT, deliberately — and this reverses an earlier choice\n *\n * The rows are created unpublished, so `lumi migrate` changes nothing a\n * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,\n * because this now runs on EVERY migrate, in production, against a database\n * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning\n * step that silently alters the public site is not additive.\n *\n * This file used to create them PUBLISHED, reasoning that \"a draft-seeded site\n * is indistinguishable from an unseeded one to every public reader\" and that\n * \"the rows are EMPTY, so publishing them renders nothing\". The first half is\n * true and is the POINT: an empty chrome IS unseeded, and a public reader\n * should see exactly what they saw before. The second half was measured and is\n * false.\n *\n * Measured, on a running front-astro against a real CMS: an empty PUBLISHED\n * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as\n * `hidden` — \"the page hid this on purpose\" — and `rendersFallback` returns\n * false for `hidden`. So publishing an empty row does not render nothing; it\n * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly\n * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where\n * before it rendered the fallback header with its Home link and nav.\n *\n * `null` (no published row) reads as `fallback` instead, which is the answer\n * that leaves the delivery template in charge until somebody has actually\n * authored a header. The moment they do and press Publish, the CMS takes over\n * — ordinary publish semantics, the same as every other document.\n *\n * ## EMPTY is what starved the canvas — and only the SITE was ever considered\n *\n * The section above is right and stays. Its blind spot is that it reasons\n * entirely about what a VISITOR sees. The rows it creates were also `layout:\n * []`, and the comment on that line said an empty tree \"is a header with no\n * blocks yet, which is what the editor opens onto\" — which is true, and is the\n * defect.\n *\n * The live site has a fallback: no published chrome row reads as `fallback`\n * and the delivery template renders its own header. **The editing canvas has\n * no fallback.** Its only channel for chrome is these rows — it is React in the\n * admin app and cannot render an Astro template's markup — so an empty row\n * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with\n * nothing in it. Every page, forever, on every project ever scaffolded. The\n * site looked right the whole time, which is why no gate and no person caught\n * it for months (`editor` D014).\n *\n * So the two properties are independent and both are required: the rows are\n * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header\n * and a footer to paint). Draft is what makes non-empty safe — the tree is\n * real, and no visitor sees it until somebody publishes.\n *\n * ## What this canNOT fix, stated because it is the interesting half\n *\n * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install\n * whose rows already exist — every project bootstrapped before this change —\n * keeps its empty ones, and must, because overwriting an authored header is the\n * one thing a bootstrap seeder may never do. The same hole reopens for anyone\n * who deletes their header's last block.\n *\n * That half belongs to the canvas and is fixed there: an empty non-page\n * document now renders a NAMED region band rather than nothing\n * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW\n * site starts with; the canvas decides that an empty region is never invisible.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s\n * bootstrap pass all establish one. Auth, the admin-only role check and the\n * production gate live there.\n *\n * The entity NAME and the `available` predicate live one module over\n * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the\n * predicate statically and must not pull this file's `server-only` client\n * along with it.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { getApp } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n CHROME_PLACEMENT_LABELS,\n CHROME_PLACEMENTS,\n type ChromePlacement,\n} from './entities/site-chrome.js'\nimport { isUniqueViolation } from './paths/apply-path.js'\n\n/**\n * The `menu` block's registered type name, and the handle each placement's\n * default nav points at.\n *\n * ## Why a block instance can be written from HERE at all\n *\n * A block instance is plain JSON — `{ id, type, props, traits }` — so seeding\n * one needs no import. That matters: `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content, see this package's\n * chrome-entity docblock), so the type NAME is the whole contract and there is\n * no definition object to reach for.\n *\n * ## Why `menu`, and not something with words in it\n *\n * A header holds navigation. `menu` is the only block in the canonical\n * vocabulary that renders navigation, it is in `canonicalBlocks` — so it is in\n * the `page` environment's BASE registry and cannot be removed by an extension\n * (`environment-registry.ts` merges base ⊕ extensions and later contributors\n * only ever shadow by name) — and `themes/structural`'s `MenuRender` paints it\n * in the default theme. So the one block this seeder writes is the one block\n * every install is guaranteed to be able to paint.\n *\n * The alternative was a `text` block carrying a site name or a copyright line.\n * That is FABRICATED CONTENT: a string in a customer's header that they did\n * not write and must now find and delete. A `menu` block carries no words of\n * ours — it renders whatever menu the operator publishes at the handle, and\n * nothing at all before then.\n *\n * ## What it looks like before a menu exists\n *\n * A fresh scaffold has no `Menu` rows: `menus:main` is not a `bootstrap`\n * seeder, so `lumi migrate` does not run it. Until one exists, `getMenu`\n * answers `null` and the block renders its own documented inert state — an\n * empty `<nav>` on the live site, and inside the editor a labelled\n * `Menu: main` placeholder that is selectable and opens `@murumets-ee/menus-ui`'s\n * tree editor (the block declares `customEditorSurface`).\n *\n * That is the honest render, and it is the useful one: the canvas shows a\n * header, in the header's place, containing the thing a header contains, and\n * clicking it is the route to the tool that fills it.\n *\n * `'main'` and `'footer'` are the handles `MenuBlock`'s own docblock names, and\n * `'main'` is what `apps/front-astro`'s fallback header already fetches\n * (`PRIMARY_MENU_HANDLE`) and what `menus:main` creates.\n */\nconst CHROME_DEFAULT_MENU: Readonly<Record<ChromePlacement, { handle: string }>> = {\n header: { handle: 'main' },\n footer: { handle: 'footer' },\n}\n\n/**\n * The block tree a bootstrapped chrome row starts life with.\n *\n * Returns a fresh array with a fresh block id per call: block ids are the keys\n * a page's `_overrides` map is written under (`compose-chrome.ts`'s\n * `tagScopedRegion` uses `block.id` as `scopeBlockId`), so two rows sharing one\n * id would make a per-page override of the header also target the footer.\n *\n * `traits: {}` rather than omitted — `BlockInstance.traits` is required, and\n * the editor's own palette inserts carry it.\n *\n * Typed as the block-tree node shape rather than `Record<string, unknown>`. The\n * looser type only compiled because the widened column-value union used to\n * include `field.richtext()`'s old `Record<string, unknown>[]` — the very type\n * that let an array be written into a text column (F105).\n */\nfunction defaultChromeLayout(placement: ChromePlacement): Array<{\n id: string\n type: string\n props: Record<string, unknown>\n traits: Record<string, unknown>\n}> {\n return [\n {\n id: randomUUID(),\n type: 'menu',\n props: { handle: CHROME_DEFAULT_MENU[placement].handle, orientation: 'horizontal' },\n traits: {},\n },\n ]\n}\n\nexport async function seedSiteChrome(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(CHROME_ENTITY_NAME)\n // Absent rather than thrown: `available` guarantees the app registered the\n // entity at the moment the list was built, and a config reloaded between\n // list and run should produce \"nothing to do\", not a stack trace.\n if (!entity) return { created: 0 }\n\n const client = createAdminClient(entity)\n let created = 0\n\n // Column refs through `getTable()` — the sanctioned accessor — never a bare\n // property on a dynamically-generated table, which would put an `any` into\n // the WHERE clause.\n const placementColumn = client.getTable().placement\n if (!placementColumn) {\n // Unreachable while the entity declares the field, and cheaper to state\n // than to debug: without it a missing column would make `eq()` build a\n // WHERE against `undefined` and the find would silently match nothing —\n // so every run would try to create, and every run after the first would\n // be absorbed by the unique-violation catch below as though it had\n // succeeded.\n throw new Error(\n `${CHROME_ENTITY_NAME} has no \"placement\" column — the chrome entity registered by this ` +\n `app is not the one @murumets-ee/content ships.`,\n )\n }\n\n for (const placement of CHROME_PLACEMENTS) {\n // Keyed on `placement`, which is also the unique column — so this find is\n // asking exactly the question the database constraint answers, and a race\n // between two callers ends in a unique violation rather than a duplicate.\n const existing = await client.findMany({ where: eq(placementColumn, placement), limit: 1 })\n if (existing.length > 0) continue\n try {\n // A POPULATED tree, not `[]` — see \"Empty is what starves the canvas\"\n // in this file's header. `defaultChromeLayout` is the one place that\n // decides what a bootstrapped region holds.\n // No `status` — `publishable()` defaults to `draft`, which is the whole\n // of the \"changes nothing a visitor sees\" property. See the header.\n await client.create({\n placement,\n label: CHROME_PLACEMENT_LABELS[placement],\n layout: defaultChromeLayout(placement),\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is a SUCCESS, not a failure: someone\n // else created the row this run was about to create, which is the\n // outcome either way. `placement`'s unique constraint is what makes that\n // safe to assume — the violation can only be the row we just looked for.\n //\n // Worth the four lines rather than trusting the find, because `lumi\n // migrate`'s bootstrap pass runs this on every migrate and two deploys\n // can overlap. Payload's equivalent conditions its write on an existence\n // check that is simply WRONG on Postgres and survives only because the\n // adapter happens to re-check underneath (R036).\n if (!isUniqueViolation(err)) throw err\n }\n }\n\n return { created }\n}\n"],"mappings":"yUAiMA,MAAM,EAA6E,CACjF,OAAQ,CAAE,OAAQ,MAAO,EACzB,OAAQ,CAAE,OAAQ,QAAS,CAC7B,EAkBA,SAAS,EAAoB,EAK1B,CACD,MAAO,CACL,CACE,GAAI,EAAW,EACf,KAAM,OACN,MAAO,CAAE,OAAQ,EAAoB,EAAU,CAAC,OAAQ,YAAa,YAAa,EAClF,OAAQ,CAAC,CACX,CACF,CACF,CAEA,eAAsB,GAA+C,CAEnE,IAAM,EADM,EACK,CAAC,CAAC,SAAS,IAAI,CAAkB,EAIlD,GAAI,CAAC,EAAQ,MAAO,CAAE,QAAS,CAAE,EAEjC,IAAM,EAAS,EAAkB,CAAM,EACnC,EAAU,EAKR,EAAkB,EAAO,SAAS,CAAC,CAAC,UAC1C,GAAI,CAAC,EAOH,MAAU,MACR,GAAG,EAAmB,iHAExB,EAGF,IAAK,IAAM,KAAa,EAKtB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAiB,CAAS,EAAG,MAAO,CAAE,CAAC,EAAA,CAC7E,OAAS,GACtB,GAAI,CAMF,MAAM,EAAO,OAAO,CAClB,YACA,MAAO,EAAwB,GAC/B,OAAQ,EAAoB,CAAS,CACvC,CAAC,EACD,GACF,OAAS,EAAK,CAWZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAGF,MAAO,CAAE,SAAQ,CACnB"}
1
+ {"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The site-chrome seeder — creates the one `site_chrome` row per placement\n * that a site needs, POPULATED, before any of them can be edited.\n *\n * Contributed as `content:site-chrome` from the `content()` plugin factory and\n * reached through the `@murumets-ee/content/seed` subpath — its own build\n * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries\n * `import 'server-only'`, off the config-load path.\n *\n * ## Why a seeder at all, and why nobody has to run it\n *\n * An unseeded chrome document is indistinguishable from a site that HAS no\n * header, and both the canvas and `/v1/_chrome` treat it that way — they\n * compose nothing and let the consumer render its own fallback. That is right,\n * and it means nothing ever CREATES the row, so without this an editor has no\n * header to open.\n *\n * Until PR30 that left the gap S5 names: nothing in the printed setup path ran\n * this, so a scaffolded project booted with an empty Site list and the only\n * bootstrap was a human pressing \"New\". The plugin now declares this seeder\n * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it\n * applies migrations — which is already step 4 of the printed next-steps. It\n * stays an ordinary named seeder too, runnable from `lumi seed` and the admin\n * Seed page; `bootstrap` adds a trigger rather than changing what it is.\n *\n * ## Why the entity is resolved by NAME\n *\n * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),\n * because an app must pass options it cannot get from here — `withOpLog()`\n * ships from `@murumets-ee/blocks/server`, and content must not depend on\n * blocks (blocks depends back on content). So there is no entity object in\n * this package to import; there is only the name the factory stamps on\n * whatever the app instantiated.\n *\n * That name is not an assumption: `site-chrome.ts` hardcodes\n * `name: 'site_chrome'`, so an app can configure the entity but cannot rename\n * it. This package's own chrome PROVIDER resolves it the same way\n * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s\n * default permission maps and `@murumets-ee/frontend-cache`'s\n * `DEFAULT_CHROME_ENTITIES` name the same string.\n *\n * ## Why it is `available`-gated rather than merely tolerant\n *\n * An app is free to register no chrome at all. Seeding plan F009 deferred this\n * seeder on exactly that: contributed unconditionally it would put a button on\n * the Seed page whose entire outcome is `created: 0`, which a reader takes as\n * \"this seeder is broken\", not as \"this project has no chrome\". The plugin\n * declares `available` so the button is ABSENT instead — the same contract\n * `rent:cars-demo` has, where absence follows from the plugin's absence rather\n * than from a silent no-op.\n *\n * ## Idempotent by construction, not by a flag\n *\n * Every run finds before it creates, keyed on `placement`. That is what makes\n * a second run a no-op — necessary now in a way it was not before, because\n * `lumi migrate` runs this on EVERY migrate, not once. It is also why the\n * find is a `where` on `placement` rather than a bare `limit: 1`: the old\n * `kind: 'singleton'` made \"is there a row\" the whole question, and under one\n * entity holding two rows it is the wrong question — a bare existence check\n * would see the header and never create the footer.\n *\n * ## DRAFT, deliberately — and this reverses an earlier choice\n *\n * The rows are created unpublished, so `lumi migrate` changes nothing a\n * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,\n * because this now runs on EVERY migrate, in production, against a database\n * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning\n * step that silently alters the public site is not additive.\n *\n * This file used to create them PUBLISHED, reasoning that \"a draft-seeded site\n * is indistinguishable from an unseeded one to every public reader\" and that\n * \"the rows are EMPTY, so publishing them renders nothing\". The first half is\n * true and is the POINT: an empty chrome IS unseeded, and a public reader\n * should see exactly what they saw before. The second half was measured and is\n * false.\n *\n * Measured, on a running front-astro against a real CMS: an empty PUBLISHED\n * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as\n * `hidden` — \"the page hid this on purpose\" — and `rendersFallback` returns\n * false for `hidden`. So publishing an empty row does not render nothing; it\n * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly\n * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where\n * before it rendered the fallback header with its Home link and nav.\n *\n * `null` (no published row) reads as `fallback` instead, which is the answer\n * that leaves the delivery template in charge until somebody has actually\n * authored a header. The moment they do and press Publish, the CMS takes over\n * — ordinary publish semantics, the same as every other document.\n *\n * ## EMPTY is what starved the canvas — and only the SITE was ever considered\n *\n * The section above is right and stays. Its blind spot is that it reasons\n * entirely about what a VISITOR sees. The rows it creates were also `layout:\n * []`, and the comment on that line said an empty tree \"is a header with no\n * blocks yet, which is what the editor opens onto\" — which is true, and is the\n * defect.\n *\n * The live site has a fallback: no published chrome row reads as `fallback`\n * and the delivery template renders its own header. **The editing canvas has\n * no fallback.** Its only channel for chrome is these rows — it is React in the\n * admin app and cannot render an Astro template's markup — so an empty row\n * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with\n * nothing in it. Every page, forever, on every project ever scaffolded. The\n * site looked right the whole time, which is why no gate and no person caught\n * it for months (`editor` D014).\n *\n * So the two properties are independent and both are required: the rows are\n * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header\n * and a footer to paint). Draft is what makes non-empty safe — the tree is\n * real, and no visitor sees it until somebody publishes.\n *\n * ## What this canNOT fix, stated because it is the interesting half\n *\n * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install\n * whose rows already exist — every project bootstrapped before this change —\n * keeps its empty ones, and must, because overwriting an authored header is the\n * one thing a bootstrap seeder may never do. The same hole reopens for anyone\n * who deletes their header's last block.\n *\n * That half belongs to the canvas and is fixed there: an empty non-page\n * document now renders a NAMED region band rather than nothing\n * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW\n * site starts with; the canvas decides that an empty region is never invisible.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s\n * bootstrap pass all establish one. Auth, the admin-only role check and the\n * production gate live there.\n *\n * The entity NAME and the `available` predicate live one module over\n * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the\n * predicate statically and must not pull this file's `server-only` client\n * along with it.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { getApp } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n CHROME_PLACEMENT_LABELS,\n CHROME_PLACEMENTS,\n type ChromePlacement,\n} from './entities/site-chrome.js'\nimport { isUniqueViolation } from './paths/apply-path.js'\nimport { toolkitPathsTable } from './paths/schema.js'\n\n/**\n * The `menu` block's registered type name, and the handle each placement's\n * default nav points at.\n *\n * ## Why a block instance can be written from HERE at all\n *\n * A block instance is plain JSON — `{ id, type, props, traits }` — so seeding\n * one needs no import. That matters: `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content, see this package's\n * chrome-entity docblock), so the type NAME is the whole contract and there is\n * no definition object to reach for.\n *\n * ## Why `menu`, and not something with words in it\n *\n * A header holds navigation. `menu` is the only block in the canonical\n * vocabulary that renders navigation, it is in `canonicalBlocks` — so it is in\n * the `page` environment's BASE registry and cannot be removed by an extension\n * (`environment-registry.ts` merges base ⊕ extensions and later contributors\n * only ever shadow by name) — and `themes/structural`'s `MenuRender` paints it\n * in the default theme. So the one block this seeder writes is the one block\n * every install is guaranteed to be able to paint.\n *\n * The alternative was a `text` block carrying a site name or a copyright line.\n * That is FABRICATED CONTENT: a string in a customer's header that they did\n * not write and must now find and delete. A `menu` block carries no words of\n * ours — it renders whatever menu the operator publishes at the handle, and\n * nothing at all before then.\n *\n * ## What it looks like before a menu exists\n *\n * A fresh scaffold has no `Menu` rows: `menus:main` is not a `bootstrap`\n * seeder, so `lumi migrate` does not run it. Until one exists, `getMenu`\n * answers `null` and the block renders its own documented inert state — an\n * empty `<nav>` on the live site, and inside the editor a labelled\n * `Menu: main` placeholder that is selectable and opens `@murumets-ee/menus-ui`'s\n * tree editor (the block declares `customEditorSurface`).\n *\n * That is the honest render, and it is the useful one: the canvas shows a\n * header, in the header's place, containing the thing a header contains, and\n * clicking it is the route to the tool that fills it.\n *\n * `'main'` and `'footer'` are the handles `MenuBlock`'s own docblock names, and\n * `'main'` is what `apps/front-astro`'s fallback header already fetches\n * (`PRIMARY_MENU_HANDLE`) and what `menus:main` creates.\n */\nconst CHROME_DEFAULT_MENU: Readonly<Record<ChromePlacement, { handle: string }>> = {\n header: { handle: 'main' },\n footer: { handle: 'footer' },\n}\n\n/**\n * The block tree a bootstrapped chrome row starts life with.\n *\n * Returns a fresh array with a fresh block id per call: block ids are the keys\n * a page's `_overrides` map is written under (`compose-chrome.ts`'s\n * `tagScopedRegion` uses `block.id` as `scopeBlockId`), so two rows sharing one\n * id would make a per-page override of the header also target the footer.\n *\n * `traits: {}` rather than omitted — `BlockInstance.traits` is required, and\n * the editor's own palette inserts carry it.\n *\n * Typed as the block-tree node shape rather than `Record<string, unknown>`. The\n * looser type only compiled because the widened column-value union used to\n * include `field.richtext()`'s old `Record<string, unknown>[]` — the very type\n * that let an array be written into a text column (F105).\n */\nfunction defaultChromeLayout(placement: ChromePlacement): Array<{\n id: string\n type: string\n props: Record<string, unknown>\n traits: Record<string, unknown>\n}> {\n return [\n {\n id: randomUUID(),\n type: 'menu',\n props: { handle: CHROME_DEFAULT_MENU[placement].handle, orientation: 'horizontal' },\n traits: {},\n },\n ]\n}\n\nexport async function seedSiteChrome(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(CHROME_ENTITY_NAME)\n // Absent rather than thrown: `available` guarantees the app registered the\n // entity at the moment the list was built, and a config reloaded between\n // list and run should produce \"nothing to do\", not a stack trace.\n if (!entity) return { created: 0 }\n\n const client = createAdminClient(entity)\n let created = 0\n\n // Column refs through `getTable()` — the sanctioned accessor — never a bare\n // property on a dynamically-generated table, which would put an `any` into\n // the WHERE clause.\n const placementColumn = client.getTable().placement\n if (!placementColumn) {\n // Unreachable while the entity declares the field, and cheaper to state\n // than to debug: without it a missing column would make `eq()` build a\n // WHERE against `undefined` and the find would silently match nothing —\n // so every run would try to create, and every run after the first would\n // be absorbed by the unique-violation catch below as though it had\n // succeeded.\n throw new Error(\n `${CHROME_ENTITY_NAME} has no \"placement\" column — the chrome entity registered by this ` +\n `app is not the one @murumets-ee/content ships.`,\n )\n }\n\n for (const placement of CHROME_PLACEMENTS) {\n // Keyed on `placement`, which is also the unique column — so this find is\n // asking exactly the question the database constraint answers, and a race\n // between two callers ends in a unique violation rather than a duplicate.\n const existing = await client.findMany({ where: eq(placementColumn, placement), limit: 1 })\n if (existing.length > 0) continue\n try {\n // A POPULATED tree, not `[]` — see \"Empty is what starves the canvas\"\n // in this file's header. `defaultChromeLayout` is the one place that\n // decides what a bootstrapped region holds.\n // No `status` — `publishable()` defaults to `draft`, which is the whole\n // of the \"changes nothing a visitor sees\" property. See the header.\n await client.create({\n placement,\n label: CHROME_PLACEMENT_LABELS[placement],\n layout: defaultChromeLayout(placement),\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is a SUCCESS, not a failure: someone\n // else created the row this run was about to create, which is the\n // outcome either way. `placement`'s unique constraint is what makes that\n // safe to assume — the violation can only be the row we just looked for.\n //\n // Worth the four lines rather than trusting the find, because `lumi\n // migrate`'s bootstrap pass runs this on every migrate and two deploys\n // can overlap. Payload's equivalent conditions its write on an existence\n // check that is simply WRONG on Postgres and survives only because the\n // adapter happens to re-check underneath (R036).\n if (!isUniqueViolation(err)) throw err\n }\n }\n\n return { created }\n}\n\n/**\n * Forget every `toolkit_paths` row — canonical and redirect history — for the\n * named entities. For a seeder that empties those entities' tables in bulk.\n *\n * `AdminClient.delete` drops a document's paths when the row is physically\n * removed (the path-index writer's `'delete'` action). A bulk reset that skips\n * the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it\n * can re-run — leaves every old URL claimed by a document that no longer\n * exists. The re-seeded documents then collide with their own ghosts and come\n * back as `/about-2`, `/welcome-2`, and the old URLs keep answering as\n * redirects to nothing (lumi-site PR04).\n *\n * Seeder-only, like the reset it follows: it asks no permission of its own,\n * exactly as a TRUNCATE does not. The seeders that call it already run as an\n * admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.\n *\n * Returns how many rows were removed, per entity, so a seeder can log it.\n */\nexport async function forgetEntityPaths(\n entityNames: readonly string[],\n): Promise<Record<string, number>> {\n const paths = toolkitPathsTable.makeClient(getApp().db.readWrite)\n const removed: Record<string, number> = {}\n for (const entityType of entityNames) {\n removed[entityType] = (await paths.deleteMany({ entityType })).length\n }\n return removed\n}\n"],"mappings":"4UAkMA,MAAM,EAA6E,CACjF,OAAQ,CAAE,OAAQ,MAAO,EACzB,OAAQ,CAAE,OAAQ,QAAS,CAC7B,EAkBA,SAAS,EAAoB,EAK1B,CACD,MAAO,CACL,CACE,GAAI,EAAW,EACf,KAAM,OACN,MAAO,CAAE,OAAQ,EAAoB,EAAU,CAAC,OAAQ,YAAa,YAAa,EAClF,OAAQ,CAAC,CACX,CACF,CACF,CAEA,eAAsB,GAA+C,CAEnE,IAAM,EADM,EACK,CAAC,CAAC,SAAS,IAAI,CAAkB,EAIlD,GAAI,CAAC,EAAQ,MAAO,CAAE,QAAS,CAAE,EAEjC,IAAM,EAAS,EAAkB,CAAM,EACnC,EAAU,EAKR,EAAkB,EAAO,SAAS,CAAC,CAAC,UAC1C,GAAI,CAAC,EAOH,MAAU,MACR,GAAG,EAAmB,iHAExB,EAGF,IAAK,IAAM,KAAa,EAKtB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAiB,CAAS,EAAG,MAAO,CAAE,CAAC,EAAA,CAC7E,OAAS,GACtB,GAAI,CAMF,MAAM,EAAO,OAAO,CAClB,YACA,MAAO,EAAwB,GAC/B,OAAQ,EAAoB,CAAS,CACvC,CAAC,EACD,GACF,OAAS,EAAK,CAWZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAGF,MAAO,CAAE,SAAQ,CACnB,CAoBA,eAAsB,EACpB,EACiC,CACjC,IAAM,EAAQ,EAAkB,WAAW,EAAO,CAAC,CAAC,GAAG,SAAS,EAC1D,EAAkC,CAAC,EACzC,IAAK,IAAM,KAAc,EACvB,EAAQ,IAAe,MAAM,EAAM,WAAW,CAAE,YAAW,CAAC,EAAA,CAAG,OAEjE,OAAO,CACT"}
@@ -1,2 +1,2 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i}from"./plugin-DEYaEeWC.mjs";import{t as a}from"./locale-utils-B0mFoV47.mjs";import{a as o,i as s}from"./context-Bw0n6P47.mjs";import{schemaRegistry as c}from"@murumets-ee/db";import{eq as l}from"drizzle-orm";import"server-only";function u(e){return e.match(/\S+|\s+/g)??[]}function d(e,t){if(e===t)return e?[{type:`equal`,value:e}]:[];if(!e)return[{type:`add`,value:t}];if(!t)return[{type:`remove`,value:e}];let n=u(e),r=u(t),i=n.length,a=r.length,o=Array.from({length:i+1},()=>Array(a+1).fill(0));for(let e=1;e<=i;e++){let t=o[e],i=o[e-1];if(!(!t||!i))for(let o=1;o<=a;o++)n[e-1]===r[o-1]?t[o]=(i[o-1]??0)+1:t[o]=Math.max(i[o]??0,t[o-1]??0)}let s=[],c=i,l=a,d=[];for(;c>0||l>0;){let e=c>0?n[c-1]:void 0,t=l>0?r[l-1]:void 0;if(c>0&&l>0&&e!==void 0&&e===t)d.push({type:`equal`,value:e}),c--,l--;else if(l>0&&t!==void 0&&(c===0||(o[c]?.[l-1]??0)>=(o[c-1]?.[l]??0)))d.push({type:`add`,value:t}),l--;else if(e!==void 0)d.push({type:`remove`,value:e}),c--;else break}d.reverse();for(let e of d){let t=s[s.length-1];t&&t.type===e.type?t.value+=e.value:s.push({...e})}return s}function f(e){return e.replace(/<[^>]*>/g,` `).replace(/\s+/g,` `).trim()}function p(e){return e==null?``:e instanceof Date?e.toISOString():typeof e==`object`?JSON.stringify(e):String(e)}function m(e,t,n,r){if(JSON.stringify(t)===JSON.stringify(n))return null;switch(r?.type??g(t,n)){case`text`:case`slug`:return{field:e,type:`text`,from:t,to:n,segments:d(p(t),p(n))};case`richtext`:return{field:e,type:`richtext`,from:t,to:n,segments:d(f(p(t)),f(p(n)))};case`number`:return{field:e,type:`number`,from:t,to:n};case`boolean`:return{field:e,type:`boolean`,from:t,to:n};case`date`:return{field:e,type:`date`,from:t,to:n};case`select`:return{field:e,type:`select`,from:t,to:n};case`media`:return{field:e,type:`media`,from:t,to:n};case`json`:return{field:e,type:`json`,from:t,to:n};case`blockTree`:return{field:e,type:`blockTree`,from:t,to:n};default:return{field:e,type:`unknown`,from:t,to:n}}}function h(e,t,n){let r=new Set([...Object.keys(e),...Object.keys(t)]),i=[];for(let a of r){if(a===`id`||a===`createdAt`||a===`updatedAt`)continue;let r=m(a,e[a],t[a],n?.[a]);r&&i.push(r)}return i}function g(e,t){let n=e??t;if(typeof n==`number`)return`number`;if(typeof n==`boolean`)return`boolean`;if(n instanceof Date)return`date`;if(typeof n==`string`)return/^\d{4}-\d{2}-\d{2}/.test(n)?`date`:`text`;if(Array.isArray(n)){let e=n[0];return n.length>0&&typeof e==`object`&&e&&`type`in e&&`id`in e?`blockTree`:`json`}return typeof n==`object`?`json`:`unknown`}function _(e,t){if(typeof e==`string`||typeof t==`string`)return d(p(e),p(t))}function v(e){if(typeof e!=`object`||!e)return null;let t=e;if(typeof t.id!=`string`||typeof t.type!=`string`)return null;let n={...t.props??{}};return t.traits!==void 0&&(n.traits=t.traits),t.children!==void 0&&(n.children=t.children),{id:t.id,type:t.type,props:n}}function y(e,t){let n=new Set([...Object.keys(e),...Object.keys(t)]),r=[];for(let i of n){let n=e[i],a=t[i];JSON.stringify(n)!==JSON.stringify(a)&&r.push({field:i,from:n,to:a,segments:_(n,a)})}return r}function b(e){let t=new Map,n=0;for(let r of e){let e=v(r);e&&(t.set(e.id,{block:e,index:n}),n++)}return t}function x(e,t){let n=b(e),r=b(t),i=[],a=new Set;for(let[e,{block:t,index:o}]of r){a.add(e);let r=n.get(e);if(!r){i.push({blockId:e,blockType:t.type,status:`added`,toIndex:o});continue}let s=r.block,c=r.index,l=y(s.props,t.props),u=c!==o;l.length===0&&!u?i.push({blockId:e,blockType:t.type,status:`unchanged`,fromIndex:c,toIndex:o}):l.length>0?i.push({blockId:e,blockType:t.type,status:`changed`,fromIndex:c,toIndex:o,propDiffs:l}):i.push({blockId:e,blockType:t.type,status:`moved`,fromIndex:c,toIndex:o})}for(let[e,{block:t,index:r}]of n)a.has(e)||i.push({blockId:e,blockType:t.type,status:`removed`,fromIndex:r});return i}function S(e){return e===`_`||e===r().defaultLocale}function C(e){let{defaultLocale:t}=r();return S(e)?{defaultLocale:t}:{locale:e,defaultLocale:t}}async function w(e,t,n,o,c){if(c!==void 0&&e.db!==c)throw Error("applyLocaleRoutedUpdate: `tx` must be the context's own db handle");let l=s(e);return e.admin.updateForLocale(t,n,o,{defaultLocale:r().defaultLocale,...c!==void 0&&{tx:c},...l!==null&&{localeStatusWriter:(e,t,n)=>(a(t),i(l,e,t,n))}})}const T=new Set([`id`,`createdAt`,`createdBy`,`updatedAt`,`updatedBy`]);function E(e,t){let n=e.allFields,r={};for(let e of Object.keys(t)){if(T.has(e)||e.startsWith(`_`)||!Object.hasOwn(n,e))continue;let i=n[e];i&&i.internal===!0||(r[e]=t[e])}return r}function D(e,t){return JSON.stringify(e)===JSON.stringify(t)}function O(e,t){let n={};for(let r of Object.keys(t))r===`id`||r.startsWith(`_`)||D(e[r],t[r])||(n[r]=t[r]);return n}var k=e({compareVersions:()=>M,createVersionSnapshot:()=>I,getVersion:()=>j,getVersions:()=>A,pruneVersions:()=>P,restoreVersion:()=>N,saveVersionSnapshot:()=>F,writeVersionColumn:()=>L});async function A(e,t,n){n?.locale!==void 0&&a(n.locale);let r=o(e);return r?await r.findMany({where:{entityId:t,locale:n?.locale??`_`},orderBy:[{column:`version`,dir:`desc`}],...n?.limit===void 0?{}:{limit:n.limit},...n?.offset===void 0?{}:{offset:n.offset}}):[]}async function j(e,t,n,r=`_`){a(r);let i=o(e);return i?await i.findOne({entityId:t,version:n,locale:r})??null:null}async function M(e,t,n,r,i=`_`){a(i);let[o,s]=await Promise.all([j(e,t,n,i),j(e,t,r,i)]);if(!o||!s)return null;let c=e.entity.allFields,l=h(o.data,s.data,c);for(let e of l)e.type===`blockTree`&&(e.blockDiffs=x(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:l,fromVersion:o,toVersion:s}}async function N(e,r,i,s=`_`){if(a(s),!o(e))throw new n(e.entity.name,`versions`);let c=await j(e,r,i,s);if(!c)throw new t(r,i,s);let l=c.data;return w(e,r,E(e.entity,l),s)}async function P(e,t,n=`_`){if(a(n),!e.versionableOptions)return;let r=o(e);if(!r)return;let i=e.versionableOptions.maxPerDoc,s=await r.findMany({where:{entityId:t,locale:n},orderBy:[{column:`version`,dir:`desc`}],limit:i});if(s.length<i)return;let c=s.map(e=>e.id);await r.deleteMany({entityId:t,locale:n,id:{notIn:c}})}async function F(e,t,n,r,i,s=`_`,c){a(s);let l=o(e);if(!l)return;let[u]=await l.aggregate({select:{maxVersion:{fn:`max`,column:`version`}},where:{entityId:t,locale:s}}),d=Number(u?.maxVersion??0)+1,f=n.status??null;if(i){let e=await l.findOne({entityId:t,isAutosave:!0,version:d,locale:s});if(e)return await l.update({id:e.id},{data:n,delta:r,status:f,createdAt:new Date}),d}return await l.insert({entityId:t,version:d,locale:s,data:n,delta:r,status:f,createdBy:c?.id??null,createdByName:c?.name??null,isAutosave:i}),d}async function I(e,t,n,r,i,o){if(i!==void 0&&a(i),!e.versionableOptions)return;let s=i??`_`,c=await F(e,t,n,r?O(r,n):null,!1,s,o);c!==void 0&&s===`_`&&await L(e,t,c),await P(e,t,s)}async function L(e,t,n){let r=c.get(e.entity.name);r&&await e.db.update(r).set({_version:n}).where(l(r.id,t))}export{P as a,k as c,E as d,w as f,h,A as i,L as l,x as m,I as n,N as o,C as p,j as r,F as s,M as t,O as u};
2
- //# sourceMappingURL=versions-qld3n7B-.mjs.map
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{i as t,n}from"./errors-NRNILhhm.mjs";import{t as r}from"./content-config-store-8omQF5p5.mjs";import{n as i}from"./plugin-BxOHoKPR.mjs";import{t as a}from"./locale-utils-B0mFoV47.mjs";import{a as o,i as s}from"./context-Bw0n6P47.mjs";import{schemaRegistry as c}from"@murumets-ee/db";import{eq as l}from"drizzle-orm";import"server-only";function u(e){return e.match(/\S+|\s+/g)??[]}function d(e,t){if(e===t)return e?[{type:`equal`,value:e}]:[];if(!e)return[{type:`add`,value:t}];if(!t)return[{type:`remove`,value:e}];let n=u(e),r=u(t),i=n.length,a=r.length,o=Array.from({length:i+1},()=>Array(a+1).fill(0));for(let e=1;e<=i;e++){let t=o[e],i=o[e-1];if(!(!t||!i))for(let o=1;o<=a;o++)n[e-1]===r[o-1]?t[o]=(i[o-1]??0)+1:t[o]=Math.max(i[o]??0,t[o-1]??0)}let s=[],c=i,l=a,d=[];for(;c>0||l>0;){let e=c>0?n[c-1]:void 0,t=l>0?r[l-1]:void 0;if(c>0&&l>0&&e!==void 0&&e===t)d.push({type:`equal`,value:e}),c--,l--;else if(l>0&&t!==void 0&&(c===0||(o[c]?.[l-1]??0)>=(o[c-1]?.[l]??0)))d.push({type:`add`,value:t}),l--;else if(e!==void 0)d.push({type:`remove`,value:e}),c--;else break}d.reverse();for(let e of d){let t=s[s.length-1];t&&t.type===e.type?t.value+=e.value:s.push({...e})}return s}function f(e){return e.replace(/<[^>]*>/g,` `).replace(/\s+/g,` `).trim()}function p(e){return e==null?``:e instanceof Date?e.toISOString():typeof e==`object`?JSON.stringify(e):String(e)}function m(e,t,n,r){if(JSON.stringify(t)===JSON.stringify(n))return null;switch(r?.type??g(t,n)){case`text`:case`slug`:return{field:e,type:`text`,from:t,to:n,segments:d(p(t),p(n))};case`richtext`:return{field:e,type:`richtext`,from:t,to:n,segments:d(f(p(t)),f(p(n)))};case`number`:return{field:e,type:`number`,from:t,to:n};case`boolean`:return{field:e,type:`boolean`,from:t,to:n};case`date`:return{field:e,type:`date`,from:t,to:n};case`select`:return{field:e,type:`select`,from:t,to:n};case`media`:return{field:e,type:`media`,from:t,to:n};case`json`:return{field:e,type:`json`,from:t,to:n};case`blockTree`:return{field:e,type:`blockTree`,from:t,to:n};default:return{field:e,type:`unknown`,from:t,to:n}}}function h(e,t,n){let r=new Set([...Object.keys(e),...Object.keys(t)]),i=[];for(let a of r){if(a===`id`||a===`createdAt`||a===`updatedAt`)continue;let r=m(a,e[a],t[a],n?.[a]);r&&i.push(r)}return i}function g(e,t){let n=e??t;if(typeof n==`number`)return`number`;if(typeof n==`boolean`)return`boolean`;if(n instanceof Date)return`date`;if(typeof n==`string`)return/^\d{4}-\d{2}-\d{2}/.test(n)?`date`:`text`;if(Array.isArray(n)){let e=n[0];return n.length>0&&typeof e==`object`&&e&&`type`in e&&`id`in e?`blockTree`:`json`}return typeof n==`object`?`json`:`unknown`}function _(e,t){if(typeof e==`string`||typeof t==`string`)return d(p(e),p(t))}function v(e){if(typeof e!=`object`||!e)return null;let t=e;if(typeof t.id!=`string`||typeof t.type!=`string`)return null;let n={...t.props??{}};return t.traits!==void 0&&(n.traits=t.traits),t.children!==void 0&&(n.children=t.children),{id:t.id,type:t.type,props:n}}function y(e,t){let n=new Set([...Object.keys(e),...Object.keys(t)]),r=[];for(let i of n){let n=e[i],a=t[i];JSON.stringify(n)!==JSON.stringify(a)&&r.push({field:i,from:n,to:a,segments:_(n,a)})}return r}function b(e){let t=new Map,n=0;for(let r of e){let e=v(r);e&&(t.set(e.id,{block:e,index:n}),n++)}return t}function x(e,t){let n=b(e),r=b(t),i=[],a=new Set;for(let[e,{block:t,index:o}]of r){a.add(e);let r=n.get(e);if(!r){i.push({blockId:e,blockType:t.type,status:`added`,toIndex:o});continue}let s=r.block,c=r.index,l=y(s.props,t.props),u=c!==o;l.length===0&&!u?i.push({blockId:e,blockType:t.type,status:`unchanged`,fromIndex:c,toIndex:o}):l.length>0?i.push({blockId:e,blockType:t.type,status:`changed`,fromIndex:c,toIndex:o,propDiffs:l}):i.push({blockId:e,blockType:t.type,status:`moved`,fromIndex:c,toIndex:o})}for(let[e,{block:t,index:r}]of n)a.has(e)||i.push({blockId:e,blockType:t.type,status:`removed`,fromIndex:r});return i}function S(e){return e===`_`||e===r().defaultLocale}function C(e){let{defaultLocale:t}=r();return S(e)?{defaultLocale:t}:{locale:e,defaultLocale:t}}async function w(e,t,n,o,c){if(c!==void 0&&e.db!==c)throw Error("applyLocaleRoutedUpdate: `tx` must be the context's own db handle");let l=s(e);return e.admin.updateForLocale(t,n,o,{defaultLocale:r().defaultLocale,...c!==void 0&&{tx:c},...l!==null&&{localeStatusWriter:(e,t,n)=>(a(t),i(l,e,t,n))}})}const T=new Set([`id`,`createdAt`,`createdBy`,`updatedAt`,`updatedBy`]);function E(e,t){let n=e.allFields,r={};for(let e of Object.keys(t)){if(T.has(e)||e.startsWith(`_`)||!Object.hasOwn(n,e))continue;let i=n[e];i&&i.internal===!0||(r[e]=t[e])}return r}function D(e,t){return JSON.stringify(e)===JSON.stringify(t)}function O(e,t){let n={};for(let r of Object.keys(t))r===`id`||r.startsWith(`_`)||D(e[r],t[r])||(n[r]=t[r]);return n}var k=e({compareVersions:()=>M,createVersionSnapshot:()=>I,getVersion:()=>j,getVersions:()=>A,pruneVersions:()=>P,restoreVersion:()=>N,saveVersionSnapshot:()=>F,writeVersionColumn:()=>L});async function A(e,t,n){n?.locale!==void 0&&a(n.locale);let r=o(e);return r?await r.findMany({where:{entityId:t,locale:n?.locale??`_`},orderBy:[{column:`version`,dir:`desc`}],...n?.limit===void 0?{}:{limit:n.limit},...n?.offset===void 0?{}:{offset:n.offset}}):[]}async function j(e,t,n,r=`_`){a(r);let i=o(e);return i?await i.findOne({entityId:t,version:n,locale:r})??null:null}async function M(e,t,n,r,i=`_`){a(i);let[o,s]=await Promise.all([j(e,t,n,i),j(e,t,r,i)]);if(!o||!s)return null;let c=e.entity.allFields,l=h(o.data,s.data,c);for(let e of l)e.type===`blockTree`&&(e.blockDiffs=x(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:l,fromVersion:o,toVersion:s}}async function N(e,r,i,s=`_`){if(a(s),!o(e))throw new n(e.entity.name,`versions`);let c=await j(e,r,i,s);if(!c)throw new t(r,i,s);let l=c.data;return w(e,r,E(e.entity,l),s)}async function P(e,t,n=`_`){if(a(n),!e.versionableOptions)return;let r=o(e);if(!r)return;let i=e.versionableOptions.maxPerDoc,s=await r.findMany({where:{entityId:t,locale:n},orderBy:[{column:`version`,dir:`desc`}],limit:i});if(s.length<i)return;let c=s.map(e=>e.id);await r.deleteMany({entityId:t,locale:n,id:{notIn:c}})}async function F(e,t,n,r,i,s=`_`,c){a(s);let l=o(e);if(!l)return;let[u]=await l.aggregate({select:{maxVersion:{fn:`max`,column:`version`}},where:{entityId:t,locale:s}}),d=Number(u?.maxVersion??0)+1,f=n.status??null;if(i){let e=await l.findOne({entityId:t,isAutosave:!0,version:d,locale:s});if(e)return await l.update({id:e.id},{data:n,delta:r,status:f,createdAt:new Date}),d}return await l.insert({entityId:t,version:d,locale:s,data:n,delta:r,status:f,createdBy:c?.id??null,createdByName:c?.name??null,isAutosave:i}),d}async function I(e,t,n,r,i,o){if(i!==void 0&&a(i),!e.versionableOptions)return;let s=i??`_`,c=await F(e,t,n,r?O(r,n):null,!1,s,o);c!==void 0&&s===`_`&&await L(e,t,c),await P(e,t,s)}async function L(e,t,n){let r=c.get(e.entity.name);r&&await e.db.update(r).set({_version:n}).where(l(r.id,t))}export{P as a,k as c,E as d,w as f,h,A as i,L as l,x as m,I as n,N as o,C as p,j as r,F as s,M as t,O as u};
2
+ //# sourceMappingURL=versions-BDxB6k_S.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"versions-qld3n7B-.mjs","names":[],"sources":["../src/diff/text-diff.ts","../src/diff/field-diff.ts","../src/diff/block-diff.ts","../src/internal/apply-locale-routed-update.ts","../src/internal/projection.ts","../src/versions.ts"],"sourcesContent":["/**\n * Word-level text diff using a simplified Myers algorithm.\n *\n * Splits text into words (preserving whitespace), computes the longest common\n * subsequence, and returns an array of diff segments.\n */\n\nimport type { DiffSegment } from './types.js'\n\n/**\n * Split text into tokens — words and whitespace runs — so diffs are\n * human-readable (\"Changed *this* word\") rather than character-level.\n */\nfunction tokenize(text: string): string[] {\n return text.match(/\\S+|\\s+/g) ?? []\n}\n\n/**\n * Compute word-level diff between two strings.\n *\n * Uses a simple LCS (dynamic programming) approach which is fine for\n * the text sizes we deal with (entity field values, not source code).\n */\nexport function diffText(from: string, to: string): DiffSegment[] {\n if (from === to) {\n return from ? [{ type: 'equal', value: from }] : []\n }\n if (!from) {\n return [{ type: 'add', value: to }]\n }\n if (!to) {\n return [{ type: 'remove', value: from }]\n }\n\n const fromTokens = tokenize(from)\n const toTokens = tokenize(to)\n\n // Build LCS table\n const m = fromTokens.length\n const n = toTokens.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))\n\n for (let i = 1; i <= m; i++) {\n const dpI = dp[i]\n const dpIm1 = dp[i - 1]\n if (!dpI || !dpIm1) continue\n for (let j = 1; j <= n; j++) {\n if (fromTokens[i - 1] === toTokens[j - 1]) {\n dpI[j] = (dpIm1[j - 1] ?? 0) + 1\n } else {\n dpI[j] = Math.max(dpIm1[j] ?? 0, dpI[j - 1] ?? 0)\n }\n }\n }\n\n // Backtrack to build diff segments\n const segments: DiffSegment[] = []\n let i = m\n let j = n\n\n // Collect in reverse, then flip\n const reversed: DiffSegment[] = []\n\n while (i > 0 || j > 0) {\n const fromTok = i > 0 ? fromTokens[i - 1] : undefined\n const toTok = j > 0 ? toTokens[j - 1] : undefined\n if (i > 0 && j > 0 && fromTok !== undefined && fromTok === toTok) {\n reversed.push({ type: 'equal', value: fromTok })\n i--\n j--\n } else if (j > 0 && toTok !== undefined && (i === 0 || (dp[i]?.[j - 1] ?? 0) >= (dp[i - 1]?.[j] ?? 0))) {\n reversed.push({ type: 'add', value: toTok })\n j--\n } else if (fromTok !== undefined) {\n reversed.push({ type: 'remove', value: fromTok })\n i--\n } else {\n // Defensive: i and j both 0, but loop condition implied otherwise.\n break\n }\n }\n\n reversed.reverse()\n\n // Merge consecutive segments of the same type\n for (const seg of reversed) {\n const last = segments[segments.length - 1]\n if (last && last.type === seg.type) {\n last.value += seg.value\n } else {\n segments.push({ ...seg })\n }\n }\n\n return segments\n}\n","/**\n * Field-level diff — dispatches to the appropriate diff strategy per field type.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { diffText } from './text-diff.js'\nimport type { DiffSegment, FieldDiff } from './types.js'\n\n/** Strip HTML tags for diffing richtext content as plain text. */\nfunction stripHtml(html: string): string {\n return html\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** Stringify a value for display (dates, objects, etc.). */\nfunction stringify(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\n/**\n * Compute diff for a single field, using the field's type to determine\n * the best diff strategy.\n */\nexport function diffField(\n fieldName: string,\n from: unknown,\n to: unknown,\n fieldConfig?: FieldConfig,\n): FieldDiff | null {\n // No change\n if (JSON.stringify(from) === JSON.stringify(to)) return null\n\n const type = fieldConfig?.type ?? inferType(from, to)\n\n switch (type) {\n case 'text':\n case 'slug': {\n const segments = diffText(stringify(from), stringify(to))\n return { field: fieldName, type: 'text', from, to, segments }\n }\n\n case 'richtext': {\n // Diff on stripped HTML so users see word changes, not tag noise\n const segments = diffText(stripHtml(stringify(from)), stripHtml(stringify(to)))\n return { field: fieldName, type: 'richtext', from, to, segments }\n }\n\n case 'number':\n return { field: fieldName, type: 'number', from, to }\n\n case 'boolean':\n return { field: fieldName, type: 'boolean', from, to }\n\n case 'date':\n return { field: fieldName, type: 'date', from, to }\n\n case 'select':\n return { field: fieldName, type: 'select', from, to }\n\n case 'media':\n return { field: fieldName, type: 'media', from, to }\n\n case 'json':\n return { field: fieldName, type: 'json', from, to }\n\n case 'blockTree':\n // Block tree — block-diff.ts computes the structural diff; just mark the\n // field type so compareVersions/compareDraftWithCurrent populate\n // `blockDiffs`.\n return { field: fieldName, type: 'blockTree', from, to }\n\n default:\n return { field: fieldName, type: 'unknown', from, to }\n }\n}\n\n/**\n * Diff all fields between two version snapshots.\n * Pass fieldConfigs to get type-aware diffs; without them, types are inferred.\n */\nexport function diffFields(\n from: Record<string, unknown>,\n to: Record<string, unknown>,\n fieldConfigs?: Record<string, FieldConfig>,\n): FieldDiff[] {\n const allKeys = new Set([...Object.keys(from), ...Object.keys(to)])\n const diffs: FieldDiff[] = []\n\n for (const key of allKeys) {\n // Skip internal/auto-generated fields\n if (key === 'id' || key === 'createdAt' || key === 'updatedAt') continue\n\n const diff = diffField(key, from[key], to[key], fieldConfigs?.[key])\n if (diff) diffs.push(diff)\n }\n\n return diffs\n}\n\n/** Best-effort type inference when no FieldConfig is available. */\nfunction inferType(from: unknown, to: unknown): string {\n const val = from ?? to\n if (typeof val === 'number') return 'number'\n if (typeof val === 'boolean') return 'boolean'\n if (val instanceof Date) return 'date'\n if (typeof val === 'string') {\n if (/^\\d{4}-\\d{2}-\\d{2}/.test(val)) return 'date'\n return 'text'\n }\n if (Array.isArray(val)) {\n const first = val[0]\n if (val.length > 0 && typeof first === 'object' && first !== null) {\n // Block tree: instances discriminated by `type` + `id`.\n if ('type' in first && 'id' in first) return 'blockTree'\n }\n return 'json'\n }\n if (typeof val === 'object') return 'json'\n return 'unknown'\n}\n\n/** Diff two string values and return segments (used by block-diff for string props). */\nexport function diffStringValues(from: unknown, to: unknown): DiffSegment[] | undefined {\n if (typeof from === 'string' || typeof to === 'string') {\n return diffText(stringify(from), stringify(to))\n }\n return undefined\n}\n","/**\n * Structural diff for block-tree arrays (`field.blockTree()` values).\n *\n * Operates on new-format `BlockInstance`s (`{ id, type, props, traits,\n * children }`) — the only block shape the toolkit stores. Blocks are\n * identified by their `id`. The algorithm detects:\n * - Added blocks (new id in target)\n * - Removed blocks (missing id in target)\n * - Moved blocks (same id, different position)\n * - Changed blocks (same id, different prop/trait/child values)\n * - Unchanged blocks\n */\n\nimport { diffStringValues } from './field-diff.js'\nimport type { BlockDiffEntry, PropDiff } from './types.js'\n\n/** Comparable view of a block instance. */\ninterface NormalizedBlock {\n id: string\n type: string\n /** Comparable content map: `props` + folded-in `traits` / `children`. */\n props: Record<string, unknown>\n}\n\n/**\n * Normalize a `BlockInstance` to its comparable `{ id, type, props }` view,\n * or null when the value isn't a recognizable block. `traits` and `children`\n * fold into the comparable map so trait edits and nested-tree changes surface\n * as prop diffs (`diffProps` skips JSON-equal values, so empty/unchanged ones\n * add no noise).\n */\nfunction normalizeBlock(val: unknown): NormalizedBlock | null {\n if (typeof val !== 'object' || val === null) return null\n const rec = val as Record<string, unknown>\n\n if (typeof rec.id !== 'string' || typeof rec.type !== 'string') return null\n\n const props: Record<string, unknown> = { ...((rec.props as Record<string, unknown>) ?? {}) }\n if (rec.traits !== undefined) props.traits = rec.traits\n if (rec.children !== undefined) props.children = rec.children\n return { id: rec.id, type: rec.type, props }\n}\n\n/**\n * Diff two prop objects within a single block, returning per-prop diffs.\n */\nfunction diffProps(\n fromProps: Record<string, unknown>,\n toProps: Record<string, unknown>,\n): PropDiff[] {\n const allKeys = new Set([...Object.keys(fromProps), ...Object.keys(toProps)])\n const diffs: PropDiff[] = []\n\n for (const key of allKeys) {\n const fromVal = fromProps[key]\n const toVal = toProps[key]\n\n if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {\n diffs.push({\n field: key,\n from: fromVal,\n to: toVal,\n segments: diffStringValues(fromVal, toVal),\n })\n }\n }\n\n return diffs\n}\n\n/**\n * Index a block array by id, assigning each block its position AMONG\n * recognizable blocks (not its raw array index). Values that fail\n * normalization are skipped without consuming a position, so a stray\n * non-block entry can't skew move detection for the real blocks around it.\n */\nfunction indexBlocks(blocks: unknown[]): Map<string, { block: NormalizedBlock; index: number }> {\n const map = new Map<string, { block: NormalizedBlock; index: number }>()\n let position = 0\n for (const raw of blocks) {\n const block = normalizeBlock(raw)\n if (block) {\n map.set(block.id, { block, index: position })\n position++\n }\n }\n return map\n}\n\n/**\n * Compute structural diff between two block arrays.\n *\n * Each block is identified by its id. Position changes, additions, removals,\n * and prop-level changes are all detected.\n */\nexport function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[] {\n const fromMap = indexBlocks(fromBlocks)\n const toMap = indexBlocks(toBlocks)\n\n const entries: BlockDiffEntry[] = []\n const processedIds = new Set<string>()\n\n // Walk the \"to\" array to preserve target order\n for (const [id, { block: toBlock, index: toIndex }] of toMap) {\n processedIds.add(id)\n const fromEntry = fromMap.get(id)\n\n if (!fromEntry) {\n // Added\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'added',\n toIndex,\n })\n continue\n }\n\n const fromBlock = fromEntry.block\n const fromIndex = fromEntry.index\n const propDiffs = diffProps(fromBlock.props, toBlock.props)\n const moved = fromIndex !== toIndex\n\n if (propDiffs.length === 0 && !moved) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'unchanged',\n fromIndex,\n toIndex,\n })\n } else if (propDiffs.length > 0) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'changed',\n fromIndex,\n toIndex,\n propDiffs,\n })\n } else {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'moved',\n fromIndex,\n toIndex,\n })\n }\n }\n\n // Removed blocks (in \"from\" but not in \"to\")\n for (const [id, { block, index }] of fromMap) {\n if (!processedIds.has(id)) {\n entries.push({\n blockId: id,\n blockType: block.type,\n status: 'removed',\n fromIndex: index,\n })\n }\n }\n\n return entries\n}\n","/**\n * Locale-routed entity write shared by `publishDraft` and `restoreVersion`.\n *\n * Since SD021, the routing itself lives in ONE place —\n * `AdminClient.updateForLocale` (`@murumets-ee/entity`): default locale →\n * base row; non-default → translatable fields to `saveTranslation`,\n * `status` to the per-locale status store, non-translatable remainder to\n * the base row. This module is the thin content-side call: it supplies the\n * plugin's `defaultLocale` and a per-call locale-status writer bound to\n * THIS context's tables, so routing is deterministic regardless of how\n * `ctx.admin` was constructed.\n *\n * Lives in its own internal module because `drafts.ts` imports\n * `versions.ts` (for `createVersionSnapshot`) — exporting this from\n * either of them would close an import cycle.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n// The LEAF definition, not the `../plugin.js` re-export — see `locale-utils.ts`'s\n// identical comment. `plugin.ts` imports this module (indirectly, through\n// `document-version-store.ts`'s contribution), so the barrel would cycle.\nimport { getContentConfig } from '../content-config-store.js'\nimport { upsertLocaleStatusRow } from '../locale-status-slot.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from '../locale-utils.js'\nimport { type ContentContext, getLocaleStatusClient } from './context.js'\n\n/** Is this snapshot-key locale the default locale (base-row) one? */\nexport function isDefaultLocale(locale: string): boolean {\n return locale === DEFAULT_LOCALE_SENTINEL || locale === getContentConfig().defaultLocale\n}\n\n/**\n * `findById` options that read the same locale view the write targets —\n * default locale reads the base row, non-default reads the\n * translation-merged row.\n */\nexport function localeFindOptions(\n locale: string,\n): { defaultLocale: string } | { locale: string; defaultLocale: string } {\n const { defaultLocale } = getContentConfig()\n return isDefaultLocale(locale) ? { defaultLocale } : { locale, defaultLocale }\n}\n\n/**\n * Apply projected entity-field data to the given locale via the SD021\n * choke point. `updateData` MUST already be projected through\n * `projectToEntityFields` — this function only delegates, it does not\n * whitelist.\n *\n * `tx` joins the row write to a caller's transaction. The locale-status writer\n * needs no tx of its own: it is bound to `ctx`, and a caller that passes `tx`\n * builds `ctx` over that same transaction (`publishDraft`), so the status row\n * commits or rolls back with the base row.\n */\nexport async function applyLocaleRoutedUpdate<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n updateData: Record<string, unknown>,\n locale: string,\n tx?: PostgresJsDatabase,\n): Promise<InferEntityDTO<F>> {\n // Bind the status writer to THIS context's locale-status table when the\n // entity has one; otherwise let updateForLocale fall back to the base\n // status column (non-translatable publishables have only the default\n // locale).\n //\n // The writer is the RAW upsert, not `setLocaleStatus`: since the publish gate\n // moved INTO `setLocaleStatus` (which now delegates to this same\n // `updateForLocale`), passing it here would re-enter the method one extra\n // level deep. `updateForLocale` has already asserted the caller's publish\n // authority from `updateData` before it reaches any writer, so the raw upsert\n // is gated by the call below — it is not a second ungated door.\n // The status writer below is bound to `ctx.db`, so a `tx` with a context NOT\n // built over it would write the row inside the transaction and the status\n // outside it — a partial publish nothing reports.\n if (tx !== undefined && ctx.db !== tx) {\n throw new Error(\"applyLocaleRoutedUpdate: `tx` must be the context's own db handle\")\n }\n const localeStatusClient = getLocaleStatusClient(ctx)\n return ctx.admin.updateForLocale(entityId, updateData as InferUpdateInput<F>, locale, {\n defaultLocale: getContentConfig().defaultLocale,\n ...(tx !== undefined && { tx }),\n ...(localeStatusClient !== null && {\n localeStatusWriter: (id: string, loc: string, status: 'draft' | 'published') => {\n // Same whitelist `setLocaleStatus` applied on this path before — an\n // unconfigured locale must not mint a status row no reader will merge.\n validateLocale(loc)\n return upsertLocaleStatusRow(localeStatusClient, id, loc, status)\n },\n }),\n })\n}\n","/**\n * Field projection + delta helpers shared between drafts and versions.\n *\n * `projectToEntityFields` is the security-critical whitelist filter\n * applied before replaying stored data through `admin.update()` —\n * see audit-security-content.md M#4 (drafts/restore must not pass\n * through forbidden fields like `status: 'published'` or\n * `_workflowStatus: 'approved'`).\n *\n * `valuesEqual` and `calculateDelta` are pure utilities used by both\n * the version-snapshot delta calculator and the block-diff prop\n * comparison — collapsing 3× repeated `JSON.stringify(a) !== JSON.stringify(b)`\n * sites identified in audit-quality-content.md DRY candidates.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\n/**\n * Identity / audit / timestamp fields that exist as real columns on every\n * entity (added by `defineEntity` / `auditable()` / `timestamped()`) and\n * MUST NOT be relayed through `admin.update()` from a draft or version\n * payload. They're either server-set (`createdAt`, `createdBy`,\n * `updatedAt`, `updatedBy`) or immutable identity (`id`).\n *\n * Without this explicit deny-list, `id` would survive `projectToEntityFields`\n * because `defineEntity` injects it into `entity.allFields` and it's not\n * marked `internal: true`. AdminClient.update's Zod schema strips it\n * downstream as a final defense, but security-critical filtering should\n * live in one place — not split across the projection AND the validator.\n */\nconst PROJECTION_DENYLIST = new Set(['id', 'createdAt', 'createdBy', 'updatedAt', 'updatedBy'])\n\n/**\n * Project a draft / version payload through the entity's declared\n * field map before forwarding to `admin.update()`. Used by\n * `publishDraft()` and `restoreVersion()` — both replay stored data\n * that originated from a write, but neither should be allowed to set\n * fields the entity didn't declare.\n *\n * Drops:\n * - the audit/identity column set above (`id`, `createdAt`,\n * `createdBy`, `updatedAt`, `updatedBy`)\n * - any key starting with `_` (behavior internals managed by hooks)\n * - any field declared `internal: true` (writable only via\n * AdminClient.updateInternal — never via public CRUD or replay)\n * - any key not in `entity.allFields` (whitelist for app-defined fields)\n */\nexport function projectToEntityFields(\n entity: Entity,\n data: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = entity.allFields as Record<string, FieldConfig>\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(data)) {\n if (PROJECTION_DENYLIST.has(key)) continue\n if (key.startsWith('_')) continue\n if (!Object.hasOwn(allowed, key)) continue\n const f = allowed[key]\n if (f && (f as { internal?: boolean }).internal === true) continue\n out[key] = data[key]\n }\n return out\n}\n\n/**\n * Structural equality via canonical JSON serialization. Coarse, with\n * three known quirks worth flagging at every call site:\n *\n * 1. **Key order matters in pathological cases.** Two objects with\n * identical keys+values but different insertion order serialize\n * differently. Safe for entity field values where keys come from\n * the same field map on both sides; not safe for arbitrary user\n * JSONB blobs.\n * 2. **Dates compare equal to their ISO string.** `new Date('2026-01-01')`\n * and `'2026-01-01T00:00:00.000Z'` both stringify to\n * `\"2026-01-01T00:00:00.000Z\"`. After a round-trip through JSONB\n * storage, a previously Date-typed field will come back as a\n * string and compare equal to its origin Date — exactly what we\n * want for delta calculation.\n * 3. **`undefined` is dropped.** `{ x: undefined }` and `{}` both\n * serialize to `{}`. If you need to distinguish \"field present\n * but null\" from \"field missing\", do not use this helper.\n */\nexport function valuesEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b)\n}\n\n/**\n * Field-level delta between previous and current entity snapshots.\n * Skips `id` and any underscore-prefixed behavior-internal field.\n * Only changed keys are returned. Used by version-snapshot persistence\n * to store a delta alongside the full snapshot for compact \"what\n * changed since last version\" reads.\n */\nexport function calculateDelta(\n previous: Record<string, unknown>,\n current: Record<string, unknown>,\n): Record<string, unknown> {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(current)) {\n if (key === 'id' || key.startsWith('_')) continue\n if (!valuesEqual(previous[key], current[key])) {\n delta[key] = current[key]\n }\n }\n return delta\n}\n","/**\n * Version-history operations on a content entity.\n *\n * All functions take a {@link ContentContext} as first argument and\n * are wired into ContentClient via thin delegation in `client.ts`.\n *\n * Capability: requires the entity to declare `versionable()`. Read\n * methods return falsy when the entity doesn't support versions\n * (defensive, supports mixed-entity loops in the dashboard); write\n * methods throw `EntityCapabilityError` mapped to HTTP 400 by admin-ui.\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO } from '@murumets-ee/entity/admin'\nimport { eq } from 'drizzle-orm'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { EntityCapabilityError, VersionNotFoundError } from './errors.js'\nimport { applyLocaleRoutedUpdate } from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getVersionsClient } from './internal/context.js'\nimport { calculateDelta, projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport type { VersionEntry } from './types.js'\n\n/**\n * List versions for an entity (newest first), filtered by locale.\n * Uses '_' as the locale value for default-locale snapshots.\n */\nexport async function getVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n options?: { limit?: number; offset?: number; locale?: string },\n): Promise<VersionEntry<InferEntityDTO<F>>[]> {\n if (options?.locale !== undefined) validateLocale(options.locale)\n const client = getVersionsClient(ctx)\n if (!client) return []\n\n const rows = await client.findMany({\n where: { entityId, locale: options?.locale ?? DEFAULT_LOCALE_SENTINEL },\n orderBy: [{ column: 'version', dir: 'desc' }],\n ...(options?.limit !== undefined ? { limit: options.limit } : {}),\n ...(options?.offset !== undefined ? { offset: options.offset } : {}),\n })\n return rows as VersionEntry<InferEntityDTO<F>>[]\n}\n\n/**\n * Get a specific version snapshot.\n */\nexport async function getVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<VersionEntry<InferEntityDTO<F>> | null> {\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, version, locale })\n return (row ?? null) as VersionEntry<InferEntityDTO<F>> | null\n}\n\n/**\n * Compare two versions and return field-level diff.\n *\n * Returns structured diffs with word-level segments for text fields\n * and structural block diffs for blocks fields.\n */\nexport async function compareVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n v1: number,\n v2: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n fromVersion: VersionEntry<InferEntityDTO<F>>\n toVersion: VersionEntry<InferEntityDTO<F>>\n} | null> {\n validateLocale(locale)\n const [version1, version2] = await Promise.all([\n getVersion(ctx, entityId, v1, locale),\n getVersion(ctx, entityId, v2, locale),\n ])\n if (!version1 || !version2) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const diffs = diffFields(version1.data, version2.data, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n return { diffs, fromVersion: version1, toVersion: version2 }\n}\n\n/**\n * Restore an entity to a previous version. Replays the snapshot data\n * through the locale-routed writer, projected through `entity.allFields`\n * so forbidden fields (workflow internals, behavior-managed underscore\n * fields, fields the entity declared `internal: true`) cannot leak.\n *\n * Snapshots are locale-keyed; the restore reads the snapshot for the\n * given locale and writes it back to the same locale's home —\n * translations for non-default locales, the base row for the default.\n */\nexport async function restoreVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getVersionsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'versions')\n }\n const versionData = await getVersion(ctx, entityId, version, locale)\n if (!versionData) {\n throw new VersionNotFoundError(entityId, version, locale)\n }\n const data = versionData.data as Record<string, unknown>\n const updateData = projectToEntityFields(ctx.entity, data)\n return applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n}\n\n/**\n * Enforce maxPerDoc retention by deleting oldest versions for a locale.\n */\nexport async function pruneVersions(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<void> {\n validateLocale(locale)\n if (!ctx.versionableOptions) return\n const client = getVersionsClient(ctx)\n if (!client) return\n\n const maxPerDoc = ctx.versionableOptions.maxPerDoc\n\n // Get IDs of versions to keep (newest N for this locale). The findMany\n // is capped at `limit: maxPerDoc`, so `keepVersions.length` is\n // `min(totalRows, maxPerDoc)` — never larger than the limit.\n const keepVersions = await client.findMany({\n where: { entityId, locale },\n orderBy: [{ column: 'version', dir: 'desc' }],\n limit: maxPerDoc,\n })\n\n // Strictly under the limit ⇒ totalRows < maxPerDoc ⇒ nothing to prune.\n // At the limit (length == maxPerDoc) we cannot distinguish \"exactly\n // maxPerDoc\" from \"more than maxPerDoc\" without another count query,\n // so we always run the DELETE — Postgres correctly removes 0-or-more\n // older rows. Better one always-cheap DELETE per prune than a guard\n // round trip on every call.\n if (keepVersions.length < maxPerDoc) return\n\n const keepIds = keepVersions.map((v) => v.id as string)\n\n // Delete everything for this entity+locale NOT in the keep list.\n await client.deleteMany({\n entityId,\n locale,\n id: { notIn: keepIds },\n })\n}\n\n/**\n * Persist a new version snapshot. Internal helper used by\n * `createVersionSnapshot` and ContentClient.update / .create.\n *\n * Attribution: ONLY the explicit `user` parameter is trusted. The\n * fallback chain that previously read `data.updatedBy` /\n * `data.createdBy` was an audit-log integrity gap (see audit\n * MEDIUM #1) — version history must never absorb a forged actor from\n * the request payload.\n */\nexport async function saveVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n data: Record<string, unknown>,\n delta: Record<string, unknown> | null,\n isAutosave: boolean,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<number | undefined> {\n // All current callers pass a pre-validated locale (or the sentinel).\n // Defensive check at the boundary so future callers — or anyone who\n // exposes this helper publicly — still get the validation.\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return\n\n // Get the next version number for this locale (max existing + 1).\n const [maxRow] = await client.aggregate<{ maxVersion: number | null }>({\n select: { maxVersion: { fn: 'max', column: 'version' } },\n where: { entityId, locale },\n })\n const version = Number(maxRow?.maxVersion ?? 0) + 1\n const status = (data.status as string) ?? null\n\n // Autosave: update the existing autosave row at the same version\n // number, preserving \"autosaves land on the latest slot\" semantics.\n if (isAutosave) {\n const existing = await client.findOne({ entityId, isAutosave: true, version, locale })\n if (existing) {\n await client.update(\n { id: existing.id as string },\n { data, delta, status, createdAt: new Date() },\n )\n return version\n }\n }\n\n await client.insert({\n entityId,\n version,\n locale,\n data,\n delta,\n status,\n createdBy: user?.id ?? null,\n createdByName: user?.name ?? null,\n isAutosave,\n })\n return version\n}\n\n/**\n * Create a version snapshot for an entity after an external update.\n * Use this when the update was performed by AdminClient directly\n * (e.g. the admin API handler) rather than through ContentClient.update().\n *\n * @param locale - The locale being edited. Use undefined or '_' for the default locale.\n * @param user - The user who made the change (id + name for display).\n */\nexport async function createVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n): Promise<void> {\n if (locale !== undefined) validateLocale(locale)\n if (!ctx.versionableOptions) return\n const snapshotLocale = locale ?? DEFAULT_LOCALE_SENTINEL\n const delta = previousData ? calculateDelta(previousData, currentData) : null\n const version = await saveVersionSnapshot(\n ctx,\n entityId,\n currentData,\n delta,\n false,\n snapshotLocale,\n user,\n )\n\n // Update _version on the entity to match (default locale only).\n if (version !== undefined && snapshotLocale === DEFAULT_LOCALE_SENTINEL) {\n await writeVersionColumn(ctx, entityId, version)\n }\n\n await pruneVersions(ctx, entityId, snapshotLocale)\n}\n\n/**\n * Write the `_version` column on the entity row.\n *\n * KNOWN EXCEPTION: this bypasses AdminClient and goes straight to the\n * entity pgTable via `schemaRegistry` + direct Drizzle. Using\n * `admin.update()` would retrigger the `versionable.beforeUpdate` hook\n * and loop indefinitely. The only ContentClient path that still writes\n * to an entity table directly.\n */\nexport async function writeVersionColumn(\n ctx: ContentContext,\n entityId: string,\n version: number,\n): Promise<void> {\n const table = schemaRegistry.get(ctx.entity.name)\n if (!table) return\n await ctx.db.update(table).set({ _version: version }).where(eq(table.id, entityId))\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"versions-BDxB6k_S.mjs","names":[],"sources":["../src/diff/text-diff.ts","../src/diff/field-diff.ts","../src/diff/block-diff.ts","../src/internal/apply-locale-routed-update.ts","../src/internal/projection.ts","../src/versions.ts"],"sourcesContent":["/**\n * Word-level text diff using a simplified Myers algorithm.\n *\n * Splits text into words (preserving whitespace), computes the longest common\n * subsequence, and returns an array of diff segments.\n */\n\nimport type { DiffSegment } from './types.js'\n\n/**\n * Split text into tokens — words and whitespace runs — so diffs are\n * human-readable (\"Changed *this* word\") rather than character-level.\n */\nfunction tokenize(text: string): string[] {\n return text.match(/\\S+|\\s+/g) ?? []\n}\n\n/**\n * Compute word-level diff between two strings.\n *\n * Uses a simple LCS (dynamic programming) approach which is fine for\n * the text sizes we deal with (entity field values, not source code).\n */\nexport function diffText(from: string, to: string): DiffSegment[] {\n if (from === to) {\n return from ? [{ type: 'equal', value: from }] : []\n }\n if (!from) {\n return [{ type: 'add', value: to }]\n }\n if (!to) {\n return [{ type: 'remove', value: from }]\n }\n\n const fromTokens = tokenize(from)\n const toTokens = tokenize(to)\n\n // Build LCS table\n const m = fromTokens.length\n const n = toTokens.length\n const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0))\n\n for (let i = 1; i <= m; i++) {\n const dpI = dp[i]\n const dpIm1 = dp[i - 1]\n if (!dpI || !dpIm1) continue\n for (let j = 1; j <= n; j++) {\n if (fromTokens[i - 1] === toTokens[j - 1]) {\n dpI[j] = (dpIm1[j - 1] ?? 0) + 1\n } else {\n dpI[j] = Math.max(dpIm1[j] ?? 0, dpI[j - 1] ?? 0)\n }\n }\n }\n\n // Backtrack to build diff segments\n const segments: DiffSegment[] = []\n let i = m\n let j = n\n\n // Collect in reverse, then flip\n const reversed: DiffSegment[] = []\n\n while (i > 0 || j > 0) {\n const fromTok = i > 0 ? fromTokens[i - 1] : undefined\n const toTok = j > 0 ? toTokens[j - 1] : undefined\n if (i > 0 && j > 0 && fromTok !== undefined && fromTok === toTok) {\n reversed.push({ type: 'equal', value: fromTok })\n i--\n j--\n } else if (j > 0 && toTok !== undefined && (i === 0 || (dp[i]?.[j - 1] ?? 0) >= (dp[i - 1]?.[j] ?? 0))) {\n reversed.push({ type: 'add', value: toTok })\n j--\n } else if (fromTok !== undefined) {\n reversed.push({ type: 'remove', value: fromTok })\n i--\n } else {\n // Defensive: i and j both 0, but loop condition implied otherwise.\n break\n }\n }\n\n reversed.reverse()\n\n // Merge consecutive segments of the same type\n for (const seg of reversed) {\n const last = segments[segments.length - 1]\n if (last && last.type === seg.type) {\n last.value += seg.value\n } else {\n segments.push({ ...seg })\n }\n }\n\n return segments\n}\n","/**\n * Field-level diff — dispatches to the appropriate diff strategy per field type.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { diffText } from './text-diff.js'\nimport type { DiffSegment, FieldDiff } from './types.js'\n\n/** Strip HTML tags for diffing richtext content as plain text. */\nfunction stripHtml(html: string): string {\n return html\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** Stringify a value for display (dates, objects, etc.). */\nfunction stringify(value: unknown): string {\n if (value === null || value === undefined) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'object') return JSON.stringify(value)\n return String(value)\n}\n\n/**\n * Compute diff for a single field, using the field's type to determine\n * the best diff strategy.\n */\nexport function diffField(\n fieldName: string,\n from: unknown,\n to: unknown,\n fieldConfig?: FieldConfig,\n): FieldDiff | null {\n // No change\n if (JSON.stringify(from) === JSON.stringify(to)) return null\n\n const type = fieldConfig?.type ?? inferType(from, to)\n\n switch (type) {\n case 'text':\n case 'slug': {\n const segments = diffText(stringify(from), stringify(to))\n return { field: fieldName, type: 'text', from, to, segments }\n }\n\n case 'richtext': {\n // Diff on stripped HTML so users see word changes, not tag noise\n const segments = diffText(stripHtml(stringify(from)), stripHtml(stringify(to)))\n return { field: fieldName, type: 'richtext', from, to, segments }\n }\n\n case 'number':\n return { field: fieldName, type: 'number', from, to }\n\n case 'boolean':\n return { field: fieldName, type: 'boolean', from, to }\n\n case 'date':\n return { field: fieldName, type: 'date', from, to }\n\n case 'select':\n return { field: fieldName, type: 'select', from, to }\n\n case 'media':\n return { field: fieldName, type: 'media', from, to }\n\n case 'json':\n return { field: fieldName, type: 'json', from, to }\n\n case 'blockTree':\n // Block tree — block-diff.ts computes the structural diff; just mark the\n // field type so compareVersions/compareDraftWithCurrent populate\n // `blockDiffs`.\n return { field: fieldName, type: 'blockTree', from, to }\n\n default:\n return { field: fieldName, type: 'unknown', from, to }\n }\n}\n\n/**\n * Diff all fields between two version snapshots.\n * Pass fieldConfigs to get type-aware diffs; without them, types are inferred.\n */\nexport function diffFields(\n from: Record<string, unknown>,\n to: Record<string, unknown>,\n fieldConfigs?: Record<string, FieldConfig>,\n): FieldDiff[] {\n const allKeys = new Set([...Object.keys(from), ...Object.keys(to)])\n const diffs: FieldDiff[] = []\n\n for (const key of allKeys) {\n // Skip internal/auto-generated fields\n if (key === 'id' || key === 'createdAt' || key === 'updatedAt') continue\n\n const diff = diffField(key, from[key], to[key], fieldConfigs?.[key])\n if (diff) diffs.push(diff)\n }\n\n return diffs\n}\n\n/** Best-effort type inference when no FieldConfig is available. */\nfunction inferType(from: unknown, to: unknown): string {\n const val = from ?? to\n if (typeof val === 'number') return 'number'\n if (typeof val === 'boolean') return 'boolean'\n if (val instanceof Date) return 'date'\n if (typeof val === 'string') {\n if (/^\\d{4}-\\d{2}-\\d{2}/.test(val)) return 'date'\n return 'text'\n }\n if (Array.isArray(val)) {\n const first = val[0]\n if (val.length > 0 && typeof first === 'object' && first !== null) {\n // Block tree: instances discriminated by `type` + `id`.\n if ('type' in first && 'id' in first) return 'blockTree'\n }\n return 'json'\n }\n if (typeof val === 'object') return 'json'\n return 'unknown'\n}\n\n/** Diff two string values and return segments (used by block-diff for string props). */\nexport function diffStringValues(from: unknown, to: unknown): DiffSegment[] | undefined {\n if (typeof from === 'string' || typeof to === 'string') {\n return diffText(stringify(from), stringify(to))\n }\n return undefined\n}\n","/**\n * Structural diff for block-tree arrays (`field.blockTree()` values).\n *\n * Operates on new-format `BlockInstance`s (`{ id, type, props, traits,\n * children }`) — the only block shape the toolkit stores. Blocks are\n * identified by their `id`. The algorithm detects:\n * - Added blocks (new id in target)\n * - Removed blocks (missing id in target)\n * - Moved blocks (same id, different position)\n * - Changed blocks (same id, different prop/trait/child values)\n * - Unchanged blocks\n */\n\nimport { diffStringValues } from './field-diff.js'\nimport type { BlockDiffEntry, PropDiff } from './types.js'\n\n/** Comparable view of a block instance. */\ninterface NormalizedBlock {\n id: string\n type: string\n /** Comparable content map: `props` + folded-in `traits` / `children`. */\n props: Record<string, unknown>\n}\n\n/**\n * Normalize a `BlockInstance` to its comparable `{ id, type, props }` view,\n * or null when the value isn't a recognizable block. `traits` and `children`\n * fold into the comparable map so trait edits and nested-tree changes surface\n * as prop diffs (`diffProps` skips JSON-equal values, so empty/unchanged ones\n * add no noise).\n */\nfunction normalizeBlock(val: unknown): NormalizedBlock | null {\n if (typeof val !== 'object' || val === null) return null\n const rec = val as Record<string, unknown>\n\n if (typeof rec.id !== 'string' || typeof rec.type !== 'string') return null\n\n const props: Record<string, unknown> = { ...((rec.props as Record<string, unknown>) ?? {}) }\n if (rec.traits !== undefined) props.traits = rec.traits\n if (rec.children !== undefined) props.children = rec.children\n return { id: rec.id, type: rec.type, props }\n}\n\n/**\n * Diff two prop objects within a single block, returning per-prop diffs.\n */\nfunction diffProps(\n fromProps: Record<string, unknown>,\n toProps: Record<string, unknown>,\n): PropDiff[] {\n const allKeys = new Set([...Object.keys(fromProps), ...Object.keys(toProps)])\n const diffs: PropDiff[] = []\n\n for (const key of allKeys) {\n const fromVal = fromProps[key]\n const toVal = toProps[key]\n\n if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {\n diffs.push({\n field: key,\n from: fromVal,\n to: toVal,\n segments: diffStringValues(fromVal, toVal),\n })\n }\n }\n\n return diffs\n}\n\n/**\n * Index a block array by id, assigning each block its position AMONG\n * recognizable blocks (not its raw array index). Values that fail\n * normalization are skipped without consuming a position, so a stray\n * non-block entry can't skew move detection for the real blocks around it.\n */\nfunction indexBlocks(blocks: unknown[]): Map<string, { block: NormalizedBlock; index: number }> {\n const map = new Map<string, { block: NormalizedBlock; index: number }>()\n let position = 0\n for (const raw of blocks) {\n const block = normalizeBlock(raw)\n if (block) {\n map.set(block.id, { block, index: position })\n position++\n }\n }\n return map\n}\n\n/**\n * Compute structural diff between two block arrays.\n *\n * Each block is identified by its id. Position changes, additions, removals,\n * and prop-level changes are all detected.\n */\nexport function diffBlocks(fromBlocks: unknown[], toBlocks: unknown[]): BlockDiffEntry[] {\n const fromMap = indexBlocks(fromBlocks)\n const toMap = indexBlocks(toBlocks)\n\n const entries: BlockDiffEntry[] = []\n const processedIds = new Set<string>()\n\n // Walk the \"to\" array to preserve target order\n for (const [id, { block: toBlock, index: toIndex }] of toMap) {\n processedIds.add(id)\n const fromEntry = fromMap.get(id)\n\n if (!fromEntry) {\n // Added\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'added',\n toIndex,\n })\n continue\n }\n\n const fromBlock = fromEntry.block\n const fromIndex = fromEntry.index\n const propDiffs = diffProps(fromBlock.props, toBlock.props)\n const moved = fromIndex !== toIndex\n\n if (propDiffs.length === 0 && !moved) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'unchanged',\n fromIndex,\n toIndex,\n })\n } else if (propDiffs.length > 0) {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'changed',\n fromIndex,\n toIndex,\n propDiffs,\n })\n } else {\n entries.push({\n blockId: id,\n blockType: toBlock.type,\n status: 'moved',\n fromIndex,\n toIndex,\n })\n }\n }\n\n // Removed blocks (in \"from\" but not in \"to\")\n for (const [id, { block, index }] of fromMap) {\n if (!processedIds.has(id)) {\n entries.push({\n blockId: id,\n blockType: block.type,\n status: 'removed',\n fromIndex: index,\n })\n }\n }\n\n return entries\n}\n","/**\n * Locale-routed entity write shared by `publishDraft` and `restoreVersion`.\n *\n * Since SD021, the routing itself lives in ONE place —\n * `AdminClient.updateForLocale` (`@murumets-ee/entity`): default locale →\n * base row; non-default → translatable fields to `saveTranslation`,\n * `status` to the per-locale status store, non-translatable remainder to\n * the base row. This module is the thin content-side call: it supplies the\n * plugin's `defaultLocale` and a per-call locale-status writer bound to\n * THIS context's tables, so routing is deterministic regardless of how\n * `ctx.admin` was constructed.\n *\n * Lives in its own internal module because `drafts.ts` imports\n * `versions.ts` (for `createVersionSnapshot`) — exporting this from\n * either of them would close an import cycle.\n */\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n// The LEAF definition, not the `../plugin.js` re-export — see `locale-utils.ts`'s\n// identical comment. `plugin.ts` imports this module (indirectly, through\n// `document-version-store.ts`'s contribution), so the barrel would cycle.\nimport { getContentConfig } from '../content-config-store.js'\nimport { upsertLocaleStatusRow } from '../locale-status-slot.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from '../locale-utils.js'\nimport { type ContentContext, getLocaleStatusClient } from './context.js'\n\n/** Is this snapshot-key locale the default locale (base-row) one? */\nexport function isDefaultLocale(locale: string): boolean {\n return locale === DEFAULT_LOCALE_SENTINEL || locale === getContentConfig().defaultLocale\n}\n\n/**\n * `findById` options that read the same locale view the write targets —\n * default locale reads the base row, non-default reads the\n * translation-merged row.\n */\nexport function localeFindOptions(\n locale: string,\n): { defaultLocale: string } | { locale: string; defaultLocale: string } {\n const { defaultLocale } = getContentConfig()\n return isDefaultLocale(locale) ? { defaultLocale } : { locale, defaultLocale }\n}\n\n/**\n * Apply projected entity-field data to the given locale via the SD021\n * choke point. `updateData` MUST already be projected through\n * `projectToEntityFields` — this function only delegates, it does not\n * whitelist.\n *\n * `tx` joins the row write to a caller's transaction. The locale-status writer\n * needs no tx of its own: it is bound to `ctx`, and a caller that passes `tx`\n * builds `ctx` over that same transaction (`publishDraft`), so the status row\n * commits or rolls back with the base row.\n */\nexport async function applyLocaleRoutedUpdate<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n updateData: Record<string, unknown>,\n locale: string,\n tx?: PostgresJsDatabase,\n): Promise<InferEntityDTO<F>> {\n // Bind the status writer to THIS context's locale-status table when the\n // entity has one; otherwise let updateForLocale fall back to the base\n // status column (non-translatable publishables have only the default\n // locale).\n //\n // The writer is the RAW upsert, not `setLocaleStatus`: since the publish gate\n // moved INTO `setLocaleStatus` (which now delegates to this same\n // `updateForLocale`), passing it here would re-enter the method one extra\n // level deep. `updateForLocale` has already asserted the caller's publish\n // authority from `updateData` before it reaches any writer, so the raw upsert\n // is gated by the call below — it is not a second ungated door.\n // The status writer below is bound to `ctx.db`, so a `tx` with a context NOT\n // built over it would write the row inside the transaction and the status\n // outside it — a partial publish nothing reports.\n if (tx !== undefined && ctx.db !== tx) {\n throw new Error(\"applyLocaleRoutedUpdate: `tx` must be the context's own db handle\")\n }\n const localeStatusClient = getLocaleStatusClient(ctx)\n return ctx.admin.updateForLocale(entityId, updateData as InferUpdateInput<F>, locale, {\n defaultLocale: getContentConfig().defaultLocale,\n ...(tx !== undefined && { tx }),\n ...(localeStatusClient !== null && {\n localeStatusWriter: (id: string, loc: string, status: 'draft' | 'published') => {\n // Same whitelist `setLocaleStatus` applied on this path before — an\n // unconfigured locale must not mint a status row no reader will merge.\n validateLocale(loc)\n return upsertLocaleStatusRow(localeStatusClient, id, loc, status)\n },\n }),\n })\n}\n","/**\n * Field projection + delta helpers shared between drafts and versions.\n *\n * `projectToEntityFields` is the security-critical whitelist filter\n * applied before replaying stored data through `admin.update()` —\n * see audit-security-content.md M#4 (drafts/restore must not pass\n * through forbidden fields like `status: 'published'` or\n * `_workflowStatus: 'approved'`).\n *\n * `valuesEqual` and `calculateDelta` are pure utilities used by both\n * the version-snapshot delta calculator and the block-diff prop\n * comparison — collapsing 3× repeated `JSON.stringify(a) !== JSON.stringify(b)`\n * sites identified in audit-quality-content.md DRY candidates.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\n/**\n * Identity / audit / timestamp fields that exist as real columns on every\n * entity (added by `defineEntity` / `auditable()` / `timestamped()`) and\n * MUST NOT be relayed through `admin.update()` from a draft or version\n * payload. They're either server-set (`createdAt`, `createdBy`,\n * `updatedAt`, `updatedBy`) or immutable identity (`id`).\n *\n * Without this explicit deny-list, `id` would survive `projectToEntityFields`\n * because `defineEntity` injects it into `entity.allFields` and it's not\n * marked `internal: true`. AdminClient.update's Zod schema strips it\n * downstream as a final defense, but security-critical filtering should\n * live in one place — not split across the projection AND the validator.\n */\nconst PROJECTION_DENYLIST = new Set(['id', 'createdAt', 'createdBy', 'updatedAt', 'updatedBy'])\n\n/**\n * Project a draft / version payload through the entity's declared\n * field map before forwarding to `admin.update()`. Used by\n * `publishDraft()` and `restoreVersion()` — both replay stored data\n * that originated from a write, but neither should be allowed to set\n * fields the entity didn't declare.\n *\n * Drops:\n * - the audit/identity column set above (`id`, `createdAt`,\n * `createdBy`, `updatedAt`, `updatedBy`)\n * - any key starting with `_` (behavior internals managed by hooks)\n * - any field declared `internal: true` (writable only via\n * AdminClient.updateInternal — never via public CRUD or replay)\n * - any key not in `entity.allFields` (whitelist for app-defined fields)\n */\nexport function projectToEntityFields(\n entity: Entity,\n data: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = entity.allFields as Record<string, FieldConfig>\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(data)) {\n if (PROJECTION_DENYLIST.has(key)) continue\n if (key.startsWith('_')) continue\n if (!Object.hasOwn(allowed, key)) continue\n const f = allowed[key]\n if (f && (f as { internal?: boolean }).internal === true) continue\n out[key] = data[key]\n }\n return out\n}\n\n/**\n * Structural equality via canonical JSON serialization. Coarse, with\n * three known quirks worth flagging at every call site:\n *\n * 1. **Key order matters in pathological cases.** Two objects with\n * identical keys+values but different insertion order serialize\n * differently. Safe for entity field values where keys come from\n * the same field map on both sides; not safe for arbitrary user\n * JSONB blobs.\n * 2. **Dates compare equal to their ISO string.** `new Date('2026-01-01')`\n * and `'2026-01-01T00:00:00.000Z'` both stringify to\n * `\"2026-01-01T00:00:00.000Z\"`. After a round-trip through JSONB\n * storage, a previously Date-typed field will come back as a\n * string and compare equal to its origin Date — exactly what we\n * want for delta calculation.\n * 3. **`undefined` is dropped.** `{ x: undefined }` and `{}` both\n * serialize to `{}`. If you need to distinguish \"field present\n * but null\" from \"field missing\", do not use this helper.\n */\nexport function valuesEqual(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b)\n}\n\n/**\n * Field-level delta between previous and current entity snapshots.\n * Skips `id` and any underscore-prefixed behavior-internal field.\n * Only changed keys are returned. Used by version-snapshot persistence\n * to store a delta alongside the full snapshot for compact \"what\n * changed since last version\" reads.\n */\nexport function calculateDelta(\n previous: Record<string, unknown>,\n current: Record<string, unknown>,\n): Record<string, unknown> {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(current)) {\n if (key === 'id' || key.startsWith('_')) continue\n if (!valuesEqual(previous[key], current[key])) {\n delta[key] = current[key]\n }\n }\n return delta\n}\n","/**\n * Version-history operations on a content entity.\n *\n * All functions take a {@link ContentContext} as first argument and\n * are wired into ContentClient via thin delegation in `client.ts`.\n *\n * Capability: requires the entity to declare `versionable()`. Read\n * methods return falsy when the entity doesn't support versions\n * (defensive, supports mixed-entity loops in the dashboard); write\n * methods throw `EntityCapabilityError` mapped to HTTP 400 by admin-ui.\n */\n\nimport 'server-only'\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO } from '@murumets-ee/entity/admin'\nimport { eq } from 'drizzle-orm'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { EntityCapabilityError, VersionNotFoundError } from './errors.js'\nimport { applyLocaleRoutedUpdate } from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getVersionsClient } from './internal/context.js'\nimport { calculateDelta, projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport type { VersionEntry } from './types.js'\n\n/**\n * List versions for an entity (newest first), filtered by locale.\n * Uses '_' as the locale value for default-locale snapshots.\n */\nexport async function getVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n options?: { limit?: number; offset?: number; locale?: string },\n): Promise<VersionEntry<InferEntityDTO<F>>[]> {\n if (options?.locale !== undefined) validateLocale(options.locale)\n const client = getVersionsClient(ctx)\n if (!client) return []\n\n const rows = await client.findMany({\n where: { entityId, locale: options?.locale ?? DEFAULT_LOCALE_SENTINEL },\n orderBy: [{ column: 'version', dir: 'desc' }],\n ...(options?.limit !== undefined ? { limit: options.limit } : {}),\n ...(options?.offset !== undefined ? { offset: options.offset } : {}),\n })\n return rows as VersionEntry<InferEntityDTO<F>>[]\n}\n\n/**\n * Get a specific version snapshot.\n */\nexport async function getVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<VersionEntry<InferEntityDTO<F>> | null> {\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, version, locale })\n return (row ?? null) as VersionEntry<InferEntityDTO<F>> | null\n}\n\n/**\n * Compare two versions and return field-level diff.\n *\n * Returns structured diffs with word-level segments for text fields\n * and structural block diffs for blocks fields.\n */\nexport async function compareVersions<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n v1: number,\n v2: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n fromVersion: VersionEntry<InferEntityDTO<F>>\n toVersion: VersionEntry<InferEntityDTO<F>>\n} | null> {\n validateLocale(locale)\n const [version1, version2] = await Promise.all([\n getVersion(ctx, entityId, v1, locale),\n getVersion(ctx, entityId, v2, locale),\n ])\n if (!version1 || !version2) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const diffs = diffFields(version1.data, version2.data, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n return { diffs, fromVersion: version1, toVersion: version2 }\n}\n\n/**\n * Restore an entity to a previous version. Replays the snapshot data\n * through the locale-routed writer, projected through `entity.allFields`\n * so forbidden fields (workflow internals, behavior-managed underscore\n * fields, fields the entity declared `internal: true`) cannot leak.\n *\n * Snapshots are locale-keyed; the restore reads the snapshot for the\n * given locale and writes it back to the same locale's home —\n * translations for non-default locales, the base row for the default.\n */\nexport async function restoreVersion<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n version: number,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!getVersionsClient(ctx)) {\n throw new EntityCapabilityError(ctx.entity.name, 'versions')\n }\n const versionData = await getVersion(ctx, entityId, version, locale)\n if (!versionData) {\n throw new VersionNotFoundError(entityId, version, locale)\n }\n const data = versionData.data as Record<string, unknown>\n const updateData = projectToEntityFields(ctx.entity, data)\n return applyLocaleRoutedUpdate(ctx, entityId, updateData, locale)\n}\n\n/**\n * Enforce maxPerDoc retention by deleting oldest versions for a locale.\n */\nexport async function pruneVersions(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<void> {\n validateLocale(locale)\n if (!ctx.versionableOptions) return\n const client = getVersionsClient(ctx)\n if (!client) return\n\n const maxPerDoc = ctx.versionableOptions.maxPerDoc\n\n // Get IDs of versions to keep (newest N for this locale). The findMany\n // is capped at `limit: maxPerDoc`, so `keepVersions.length` is\n // `min(totalRows, maxPerDoc)` — never larger than the limit.\n const keepVersions = await client.findMany({\n where: { entityId, locale },\n orderBy: [{ column: 'version', dir: 'desc' }],\n limit: maxPerDoc,\n })\n\n // Strictly under the limit ⇒ totalRows < maxPerDoc ⇒ nothing to prune.\n // At the limit (length == maxPerDoc) we cannot distinguish \"exactly\n // maxPerDoc\" from \"more than maxPerDoc\" without another count query,\n // so we always run the DELETE — Postgres correctly removes 0-or-more\n // older rows. Better one always-cheap DELETE per prune than a guard\n // round trip on every call.\n if (keepVersions.length < maxPerDoc) return\n\n const keepIds = keepVersions.map((v) => v.id as string)\n\n // Delete everything for this entity+locale NOT in the keep list.\n await client.deleteMany({\n entityId,\n locale,\n id: { notIn: keepIds },\n })\n}\n\n/**\n * Persist a new version snapshot. Internal helper used by\n * `createVersionSnapshot` and ContentClient.update / .create.\n *\n * Attribution: ONLY the explicit `user` parameter is trusted. The\n * fallback chain that previously read `data.updatedBy` /\n * `data.createdBy` was an audit-log integrity gap (see audit\n * MEDIUM #1) — version history must never absorb a forged actor from\n * the request payload.\n */\nexport async function saveVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n data: Record<string, unknown>,\n delta: Record<string, unknown> | null,\n isAutosave: boolean,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<number | undefined> {\n // All current callers pass a pre-validated locale (or the sentinel).\n // Defensive check at the boundary so future callers — or anyone who\n // exposes this helper publicly — still get the validation.\n validateLocale(locale)\n const client = getVersionsClient(ctx)\n if (!client) return\n\n // Get the next version number for this locale (max existing + 1).\n const [maxRow] = await client.aggregate<{ maxVersion: number | null }>({\n select: { maxVersion: { fn: 'max', column: 'version' } },\n where: { entityId, locale },\n })\n const version = Number(maxRow?.maxVersion ?? 0) + 1\n const status = (data.status as string) ?? null\n\n // Autosave: update the existing autosave row at the same version\n // number, preserving \"autosaves land on the latest slot\" semantics.\n if (isAutosave) {\n const existing = await client.findOne({ entityId, isAutosave: true, version, locale })\n if (existing) {\n await client.update(\n { id: existing.id as string },\n { data, delta, status, createdAt: new Date() },\n )\n return version\n }\n }\n\n await client.insert({\n entityId,\n version,\n locale,\n data,\n delta,\n status,\n createdBy: user?.id ?? null,\n createdByName: user?.name ?? null,\n isAutosave,\n })\n return version\n}\n\n/**\n * Create a version snapshot for an entity after an external update.\n * Use this when the update was performed by AdminClient directly\n * (e.g. the admin API handler) rather than through ContentClient.update().\n *\n * @param locale - The locale being edited. Use undefined or '_' for the default locale.\n * @param user - The user who made the change (id + name for display).\n */\nexport async function createVersionSnapshot(\n ctx: ContentContext,\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n): Promise<void> {\n if (locale !== undefined) validateLocale(locale)\n if (!ctx.versionableOptions) return\n const snapshotLocale = locale ?? DEFAULT_LOCALE_SENTINEL\n const delta = previousData ? calculateDelta(previousData, currentData) : null\n const version = await saveVersionSnapshot(\n ctx,\n entityId,\n currentData,\n delta,\n false,\n snapshotLocale,\n user,\n )\n\n // Update _version on the entity to match (default locale only).\n if (version !== undefined && snapshotLocale === DEFAULT_LOCALE_SENTINEL) {\n await writeVersionColumn(ctx, entityId, version)\n }\n\n await pruneVersions(ctx, entityId, snapshotLocale)\n}\n\n/**\n * Write the `_version` column on the entity row.\n *\n * KNOWN EXCEPTION: this bypasses AdminClient and goes straight to the\n * entity pgTable via `schemaRegistry` + direct Drizzle. Using\n * `admin.update()` would retrigger the `versionable.beforeUpdate` hook\n * and loop indefinitely. The only ContentClient path that still writes\n * to an entity table directly.\n */\nexport async function writeVersionColumn(\n ctx: ContentContext,\n entityId: string,\n version: number,\n): Promise<void> {\n const table = schemaRegistry.get(ctx.entity.name)\n if (!table) return\n await ctx.db.update(table).set({ _version: version }).where(eq(table.id, entityId))\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/content",
3
- "version": "0.75.1",
3
+ "version": "0.76.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,14 +56,14 @@
56
56
  "drizzle-orm": "^0.45.2",
57
57
  "server-only": "^0.0.1",
58
58
  "zod": "^3.24.1",
59
- "@murumets-ee/blocks": "0.75.1",
60
- "@murumets-ee/core": "0.75.1",
61
- "@murumets-ee/db": "0.75.1",
62
- "@murumets-ee/entity": "0.75.1",
63
- "@murumets-ee/i18n": "0.75.1",
64
- "@murumets-ee/logging": "0.75.1",
65
- "@murumets-ee/queue": "0.75.1",
66
- "@murumets-ee/settings": "0.75.1"
59
+ "@murumets-ee/blocks": "0.76.0",
60
+ "@murumets-ee/core": "0.76.0",
61
+ "@murumets-ee/db": "0.76.0",
62
+ "@murumets-ee/entity": "0.76.0",
63
+ "@murumets-ee/i18n": "0.76.0",
64
+ "@murumets-ee/logging": "0.76.0",
65
+ "@murumets-ee/queue": "0.76.0",
66
+ "@murumets-ee/settings": "0.76.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^20.19.42",
@@ -1,2 +0,0 @@
1
- import{matchReservedPrefix as e}from"@murumets-ee/entity";function t(e){let t=e;for(let e=0;t&&e<5;e++){if(t.code===`23505`)return!0;t=t.cause}return!1}const n=new Set([`not-routable`,`parent-not-indexed`,`row-unreadable`]);async function r(r){let{pathsClient:a,entityType:o,entityId:s,locale:c,path:l,reservedPathPrefixes:u,logger:d}=r;if(l===null){let e=r.skipReason??null,t=e!==null&&(r.suppressNoteworthy?.includes(e)??!1);return e!==null&&!t&&n.has(e)&&d.warn({entity:o,id:s,locale:c,skipReason:e},`toolkit_paths: no canonical path computed for this (document, locale) — ${e}. The document has no toolkit_paths row and will 404 on the public site until this is resolved (fix the input and re-save, or run \`lumi paths:rebuild\`).`),{outcome:`skipped-null`,oldPath:null,newPath:null,conflictWith:null,skipReason:e}}let f=e(l,u);if(f!==void 0)return d.warn({entity:o,id:s,locale:c,path:l,reserved:f},`toolkit_paths: computed path falls under a reserved prefix — skipping this locale (kept out of the routable index, see F013)`),{outcome:`skipped-reserved`,oldPath:null,newPath:l,conflictWith:null,skipReason:null};let p=await a.findOne({entityType:o,entityId:s,locale:c,isCanonical:!0}),m=p?.path??null;if(p!==null&&p.path===l)return{outcome:`unchanged`,oldPath:m,newPath:l,conflictWith:null,skipReason:null};if(r.dryRun){let e=await a.findOne({locale:c,path:l,isCanonical:!0});return e!==null&&!(e.entityType===o&&e.entityId===s)?{outcome:`conflict`,oldPath:m,newPath:l,conflictWith:{entityType:e.entityType,entityId:e.entityId},skipReason:null}:{outcome:p===null?`created`:`renamed`,oldPath:m,newPath:l,conflictWith:null,skipReason:null}}p!==null&&await a.update({id:p.id},{isCanonical:!1,updatedAt:new Date});let h=!1;try{await a.insert({entityType:o,entityId:s,locale:c,path:l,isCanonical:!0}),h=!0}catch(e){if(!t(e))throw e}if(!h){if(p!==null)try{await a.update({id:p.id},{isCanonical:!0,updatedAt:new Date})}catch(e){if(!t(e))throw e;d.warn({entity:o,id:s,locale:c,path:l},`toolkit_paths: could not re-promote the old canonical after a failed insert — a canonical row already exists for this document+locale (concurrent write); leaving history as-is`)}let e=await a.findOne({locale:c,path:l,isCanonical:!0}),n=e!==null&&!(e.entityType===o&&e.entityId===s)?{entityType:e.entityType,entityId:e.entityId}:null;return d.warn({entity:o,id:s,locale:c,path:l},`toolkit_paths: canonical (locale, path) already claimed by another document — skipping (old canonical restored / left in place)`),{outcome:`conflict`,oldPath:m,newPath:l,conflictWith:n,skipReason:null}}return p===null?{outcome:`created`,oldPath:null,newPath:l,conflictWith:null,skipReason:null}:(await i(a,o,s,c),{outcome:`renamed`,oldPath:m,newPath:l,conflictWith:null,skipReason:null})}async function i(e,t,n,r){let i=await e.count({entityType:t,entityId:n,locale:r,isCanonical:!1})-25;if(i<=0)return;let a=(await e.findMany({where:{entityType:t,entityId:n,locale:r,isCanonical:!1},orderBy:[{column:`updatedAt`,dir:`asc`},{column:`createdAt`,dir:`asc`}],limit:Math.min(i,1e3)})).map(e=>e.id);a.length>0&&await e.deleteMany({id:{in:a}})}async function a(e,t,n){let r=e.length;if(r===0)return;let i=0,a=Math.max(1,Math.min(Number.isFinite(t)?Math.floor(t):1,r));async function o(){for(;;){let t=i;if(i+=1,t>=r)return;await n(e[t],t)}}await Promise.all(Array.from({length:a},()=>o()))}export{t as n,a as r,r as t};
2
- //# sourceMappingURL=apply-path-s-YeCUXP.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"apply-path-s-YeCUXP.mjs","names":[],"sources":["../src/paths/apply-path.ts"],"sourcesContent":["/**\n * Shared `toolkit_paths` canonical-row apply/plan logic (plan/routing PR04).\n *\n * PR04 Task 1 inlined a find-canonical → insert/demote sequence inside\n * `createPathIndexWriter`'s per-locale closure. Task 2 (the descendant-cascade\n * + full-rebuild queue jobs) needs the EXACT same \"recompute-and-demote-if-\n * changed\" write so a renamed ancestor's descendants — and a `paths:rebuild`\n * sweep — mint redirect history identically to a first-party entity write.\n *\n * Rather than reimplement it (and risk drift in the routing invariant), the\n * sequence lives here once as {@link applyCanonicalPath}; the entity-hook\n * writer, the cascade job, and the rebuild job all call it. The writer adds the\n * one thing the jobs must NOT do — firing the cascade seam — on a `renamed`\n * outcome; the jobs write descendant paths DIRECTLY through this helper (never\n * via `AdminClient.update`) precisely so they DON'T re-fire the seam and trip\n * the queue's self-enqueue guard.\n *\n * NOTE: on the content plugin's `init` load path (via `writers.ts` →\n * `plugin.ts`), so it must stay free of `import 'server-only'`. Every import\n * here is server-only-free; `Logger` is a TYPE-only import from core.\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport type { ToolkitPathsClient } from './schema.js'\n\n/**\n * Per-document redirect-history cap (plan/routing §2 \"capped at 25/document\").\n * When a rename demotes the old canonical row, non-canonical rows beyond this\n * many (per document+locale) are pruned oldest-demoted-first to bound growth.\n * Moved here from `writers.ts` so both the entity-hook writer and the jobs\n * share ONE cap constant.\n */\nexport const MAX_REDIRECT_HISTORY = 25\n\n/**\n * Steady-state overflow is 1 (we trim on every demote); this clamp only guards\n * a pathological backlog (e.g. after a rebuild that demoted many at once) from\n * tripping the `findMany` MAX_LIMIT throw — the remainder self-heals on the\n * next write.\n */\nconst HISTORY_TRIM_MAX = 1000\n\n/** True iff `err` (or any `.cause` in its chain) is a Postgres unique violation (23505). */\nexport function isUniqueViolation(err: unknown): boolean {\n let cursor: unknown = err\n for (let depth = 0; cursor && depth < 5; depth++) {\n if ((cursor as { code?: unknown }).code === '23505') return true\n cursor = (cursor as { cause?: unknown }).cause\n }\n return false\n}\n\n/**\n * WHY a (document, locale) has no computed path — carried alongside the\n * `skipped-null` outcome so a caller/operator can tell \"this row has no path\n * by design\" from \"this row should have had one\" (F075). Produced by\n * {@link import('./generate-path.js').computeCanonicalPath}, except\n * `row-unreadable`, which the writer sets itself when it could not even READ\n * the row (see `createPathIndexWriter` in `writers.ts`).\n *\n * - `not-routable` — `entity.frontend?.path` is undefined. Defensive: nothing\n * should call `computeCanonicalPath` for a non-routable entity in the\n * first place, so this reason firing at all is itself the anomaly.\n * - `no-pattern-for-locale` — `resolvePattern` has no pattern for this\n * locale. EXPECTED for an entity that is intentionally routable in only\n * some locales.\n * - `parent-not-indexed` — the pattern needs `[parent.path]`, the row has a\n * parent, but the parent has no canonical `toolkit_paths` row YET.\n * Ordering-sensitive (PR23's collection-order defect produced exactly\n * this) — a well-ordered write should not hit it in steady state, so it is\n * treated as noteworthy rather than expected.\n * - `unresolved-token` — `buildEntityHref` returned null (a required token,\n * e.g. `[slug]`, has no value yet). EXPECTED for a document mid-creation.\n * - `row-unreadable` — the writer's recompute read came back `null` (the row\n * is trashed, gone, or — before F074's fix — the caller lacked `view` on\n * it). Always noteworthy: a row that legitimately has no path never\n * reaches this branch, because there is no row to read a path FOR.\n */\nexport type PathSkipReason =\n | 'not-routable'\n | 'no-pattern-for-locale'\n | 'parent-not-indexed'\n | 'unresolved-token'\n | 'row-unreadable'\n\n/**\n * `skipReason`s worth a `logger.warn`. The other reasons are legitimate,\n * everyday states — a document mid-creation with no slug yet, an entity\n * routable in only some locales — and warning on every one of THOSE would\n * train operators to ignore the message, which is worse than the silence\n * F075 replaces (CLAUDE.md \"negative-test every gate\": a check nobody reads\n * is not a check). These are the reasons a WELL-FORMED write should never\n * produce, so seeing one at all is the signal.\n */\nconst NOTEWORTHY_SKIP_REASONS: ReadonlySet<PathSkipReason> = new Set([\n 'not-routable',\n 'parent-not-indexed',\n 'row-unreadable',\n])\n\n/**\n * What {@link applyCanonicalPath} did (or, in `dryRun`, WOULD do) for one\n * (document, locale):\n * - `created` — no canonical row existed → a fresh canonical was inserted.\n * - `unchanged`— the canonical row already held this exact path (no-op).\n * - `renamed` — the path changed → new canonical inserted, old demoted to\n * history (redirect), history pruned to the cap.\n * - `skipped-null` — no path was available for this (document, locale) — see\n * {@link ApplyPathResult.skipReason} for WHY.\n * - `skipped-reserved` — the computed path falls under a reserved prefix; it\n * is kept OUT of the routable index (never persisted).\n * - `conflict` — the target `(locale, path)` canonical is owned by a DIFFERENT\n * document; the old row is left canonical (nothing written).\n */\nexport type ApplyPathOutcome =\n | 'created'\n | 'unchanged'\n | 'renamed'\n | 'skipped-null'\n | 'skipped-reserved'\n | 'conflict'\n\n/** Structured outcome of one canonical-path apply/plan. */\nexport interface ApplyPathResult {\n outcome: ApplyPathOutcome\n /** The document's previous canonical path, or `null` when it had none. */\n oldPath: string | null\n /** The freshly computed canonical path, or `null` for `skipped-null`. */\n newPath: string | null\n /** For `conflict`: the document already owning the target URL (else `null`). */\n conflictWith: { entityType: string; entityId: string } | null\n /**\n * WHY the outcome is `skipped-null` (F075) — `null` for every other\n * outcome, and also `null` for a `skipped-null` whose caller didn't supply\n * a reason (older/test call sites; treated as \"nothing to report\").\n */\n skipReason: PathSkipReason | null\n}\n\nexport interface ApplyCanonicalPathArgs {\n pathsClient: ToolkitPathsClient\n entityType: string\n entityId: string\n locale: string\n /**\n * Precomputed canonical path (from `computeCanonicalPath`), or `null` for a\n * typed broken link. Kept as an explicit input so the caller owns the\n * entity+row → path resolution (the writer, cascade, and rebuild each source\n * `row` differently) and this helper stays a pure `toolkit_paths` mutator.\n */\n path: string | null\n /**\n * WHY `path` is `null` (F075) — required when `path` is `null` so the\n * `skipped-null` outcome is never reported with no explanation; omit\n * entirely when `path` is non-null; it is ignored either way.\n */\n skipReason?: PathSkipReason\n /**\n * Reasons this CALLER already knows are transient in its own context, so a\n * hit should not warn even though {@link NOTEWORTHY_SKIP_REASONS} would\n * normally treat it as one. The one caller that needs this today:\n * `runPathsRebuild`'s multi-pass convergence sweep scans documents in\n * ascending id (uncorrelated with hierarchy depth — see its own docblock),\n * so on a genuinely COLD `toolkit_paths` index a descendant scanned before\n * its not-yet-created ancestor legitimately hits `parent-not-indexed` on\n * pass 1 and self-heals by the ancestor's own pass. `runPathsCascade`'s BFS\n * is ancestor-first by construction and the entity-hook writer never\n * recomputes an unindexed ancestor's descendant in the same call, so\n * NEITHER passes this — for them, `parent-not-indexed` staying noteworthy\n * is exactly the signal that caught PR23's own collection-order defect.\n */\n suppressNoteworthy?: readonly PathSkipReason[]\n reservedPathPrefixes: readonly string[]\n logger: Logger\n /**\n * When `true`, compute the outcome + diff but perform NO writes (the rebuild\n * job's dry-run preview). Conflict is then detected via a lookup rather than\n * the insert-and-catch used on the real-write path.\n */\n dryRun?: boolean\n}\n\n/**\n * Recompute-and-demote-if-changed for ONE (document, locale) — the shared core\n * of PR04's redirect-history writer, reused by the entity hook AND the cascade\n * / rebuild jobs.\n *\n * Real-write path (default): the old canonical is DEMOTED FIRST\n * (`is_canonical = false`), then the new canonical is inserted. This ordering is\n * DB-SAFE because `toolkit_paths` carries a partial unique\n * `UNIQUE (entity_type, entity_id, locale) WHERE is_canonical` — so at every\n * point in time Postgres guarantees at most ONE canonical row per\n * (document, locale). (An insert-FIRST order would transiently hold two\n * canonical rows and therefore VIOLATE that partial unique on every rename.)\n *\n * If the insert then fails with a unique violation — either the pre-existing\n * `(locale, path)` conflict (another document owns the target URL, F010's known\n * tolerance) or, under a genuine concurrent double-write, the\n * `(entity_type, entity_id, locale)` partial unique — the old row is\n * RE-PROMOTED (`is_canonical = true`) so the document is never left with zero\n * canonical rows for the locale. That corrective re-promote can itself hit the\n * partial unique in the concurrent case (another writer already re-inserted a\n * canonical for this document); that is tolerated + logged, because the document\n * already HAS a canonical then. On success the old row's demotion stands and\n * history is pruned to {@link MAX_REDIRECT_HISTORY}.\n *\n * It NEVER fires the cascade seam — the caller decides that on a `renamed`\n * outcome (only the entity hook does; the jobs must not, to avoid re-entrant\n * self-enqueue).\n *\n * `dryRun` path performs no writes and detects a cross-document conflict with a\n * `(locale, path, isCanonical)` lookup.\n */\nexport async function applyCanonicalPath(args: ApplyCanonicalPathArgs): Promise<ApplyPathResult> {\n const { pathsClient, entityType, entityId, locale, path, reservedPathPrefixes, logger } = args\n\n if (path === null) {\n // F075 — a `skipped-null` used to leave no trace at all. Most reasons are\n // legitimate everyday states (see NOTEWORTHY_SKIP_REASONS's docblock) and\n // stay silent; the reasons a well-formed write should never hit get a\n // `warn` so an operator debugging \"this page is a 404\" has somewhere to\n // look instead of reasoning about the code from scratch.\n const skipReason = args.skipReason ?? null\n const isSuppressed = skipReason !== null && (args.suppressNoteworthy?.includes(skipReason) ?? false)\n if (skipReason !== null && !isSuppressed && NOTEWORTHY_SKIP_REASONS.has(skipReason)) {\n logger.warn(\n { entity: entityType, id: entityId, locale, skipReason },\n `toolkit_paths: no canonical path computed for this (document, locale) — ${skipReason}. ` +\n 'The document has no toolkit_paths row and will 404 on the public site until this is ' +\n 'resolved (fix the input and re-save, or run `lumi paths:rebuild`).',\n )\n }\n return { outcome: 'skipped-null', oldPath: null, newPath: null, conflictWith: null, skipReason }\n }\n\n // Never persist a reserved path (F013). The write-time guard only screens the\n // write's OWN locale, and a rebuild sweeps every locale — so re-check here.\n const reserved = matchReservedPrefix(path, reservedPathPrefixes)\n if (reserved !== undefined) {\n logger.warn(\n { entity: entityType, id: entityId, locale, path, reserved },\n 'toolkit_paths: computed path falls under a reserved prefix — skipping this locale ' +\n '(kept out of the routable index, see F013)',\n )\n return {\n outcome: 'skipped-reserved',\n oldPath: null,\n newPath: path,\n conflictWith: null,\n skipReason: null,\n }\n }\n\n // The document's CURRENT canonical row for this locale. The `isCanonical`\n // filter is REQUIRED since PR04 keeps historical (`is_canonical = false`) rows.\n const currentCanonical = await pathsClient.findOne({\n entityType,\n entityId,\n locale,\n isCanonical: true,\n })\n const oldPath = currentCanonical?.path ?? null\n\n // (b) Same path → unrelated field changed; a no-op. Leave the row (and its\n // updatedAt) alone; don't mint spurious history.\n if (currentCanonical !== null && currentCanonical.path === path) {\n return { outcome: 'unchanged', oldPath, newPath: path, conflictWith: null, skipReason: null }\n }\n\n if (args.dryRun) {\n const owner = await pathsClient.findOne({ locale, path, isCanonical: true })\n if (owner !== null && !(owner.entityType === entityType && owner.entityId === entityId)) {\n return {\n outcome: 'conflict',\n oldPath,\n newPath: path,\n conflictWith: { entityType: owner.entityType, entityId: owner.entityId },\n skipReason: null,\n }\n }\n return {\n outcome: currentCanonical === null ? 'created' : 'renamed',\n oldPath,\n newPath: path,\n conflictWith: null,\n skipReason: null,\n }\n }\n\n // Real write. DEMOTE the old canonical FIRST so the new insert can't\n // transiently create a second canonical row — the\n // `(entity_type, entity_id, locale) WHERE is_canonical` partial unique would\n // otherwise reject the insert on every rename. Demoting first keeps at most one\n // canonical per (document, locale) at all times (enforced by Postgres).\n if (currentCanonical !== null) {\n await pathsClient.update(\n { id: currentCanonical.id },\n { isCanonical: false, updatedAt: new Date() },\n )\n }\n\n // Insert the new canonical. On a unique violation the target URL is already\n // owned by another document (F010) — or, under a concurrent double-write, this\n // document's canonical was just re-inserted by the other writer.\n let inserted = false\n try {\n await pathsClient.insert({ entityType, entityId, locale, path, isCanonical: true })\n inserted = true\n } catch (err) {\n if (!isUniqueViolation(err)) throw err\n }\n\n if (!inserted) {\n // Re-promote the old row so the document is never left with ZERO canonical\n // rows for this locale (this is what the demote-first order must protect\n // against). The re-promote can ITSELF hit the partial unique when a\n // concurrent writer has already re-inserted a canonical for this document —\n // in which case the document already has a canonical and no repair is\n // needed; tolerate + log (F010/F013 log-and-tolerate posture).\n if (currentCanonical !== null) {\n try {\n await pathsClient.update(\n { id: currentCanonical.id },\n { isCanonical: true, updatedAt: new Date() },\n )\n } catch (err) {\n if (!isUniqueViolation(err)) throw err\n logger.warn(\n { entity: entityType, id: entityId, locale, path },\n 'toolkit_paths: could not re-promote the old canonical after a failed insert — ' +\n 'a canonical row already exists for this document+locale (concurrent write); ' +\n 'leaving history as-is',\n )\n }\n }\n // Best-effort: identify who owns the URL for structured reporting.\n const owner = await pathsClient.findOne({ locale, path, isCanonical: true })\n const conflictWith =\n owner !== null && !(owner.entityType === entityType && owner.entityId === entityId)\n ? { entityType: owner.entityType, entityId: owner.entityId }\n : null\n logger.warn(\n { entity: entityType, id: entityId, locale, path },\n 'toolkit_paths: canonical (locale, path) already claimed by another document — ' +\n 'skipping (old canonical restored / left in place)',\n )\n return { outcome: 'conflict', oldPath, newPath: path, conflictWith, skipReason: null }\n }\n\n if (currentCanonical === null) {\n return { outcome: 'created', oldPath: null, newPath: path, conflictWith: null, skipReason: null }\n }\n\n // Old canonical was demoted above; prune redirect history to the cap.\n await enforceHistoryCap(pathsClient, entityType, entityId, locale)\n return { outcome: 'renamed', oldPath, newPath: path, conflictWith: null, skipReason: null }\n}\n\n/**\n * Prune redirect history for (document, locale) back down to\n * {@link MAX_REDIRECT_HISTORY}, trimming the rows demoted longest ago first\n * (smallest `updated_at`). Extracted verbatim from Task 1's writer closure.\n */\nasync function enforceHistoryCap(\n pathsClient: ToolkitPathsClient,\n entityType: string,\n entityId: string,\n locale: string,\n): Promise<void> {\n const historical = await pathsClient.count({\n entityType,\n entityId,\n locale,\n isCanonical: false,\n })\n const overflow = historical - MAX_REDIRECT_HISTORY\n if (overflow <= 0) return\n const oldest = await pathsClient.findMany({\n where: { entityType, entityId, locale, isCanonical: false },\n orderBy: [\n { column: 'updatedAt', dir: 'asc' },\n { column: 'createdAt', dir: 'asc' },\n ],\n limit: Math.min(overflow, HISTORY_TRIM_MAX),\n })\n const trimIds = oldest.map((r) => r.id)\n if (trimIds.length > 0) {\n await pathsClient.deleteMany({ id: { in: trimIds } })\n }\n}\n\n/**\n * Run `fn` over `items` with bounded concurrency (a fixed worker pool pulling\n * from a shared cursor) — the `runMeritBatch` shape (CLAUDE.md's\n * no-unbounded-fan-out rule; never `Promise.all` over an unbounded list). `fn`\n * MUST isolate its own per-item errors (this helper does not catch) so one bad\n * item can't abort the batch.\n *\n * Increments/tallies inside `fn` are safe without a lock: JS is single-threaded,\n * so a `count++` between `await` points is atomic.\n */\nexport async function mapBounded<I>(\n items: readonly I[],\n concurrency: number,\n fn: (item: I, index: number) => Promise<void>,\n): Promise<void> {\n const total = items.length\n if (total === 0) return\n let next = 0\n // `Math.floor` guards a fractional/NaN concurrency (config-derived) from\n // producing a zero-length worker array that silently no-ops the batch.\n const safe = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1\n const workerCount = Math.max(1, Math.min(safe, total))\n async function worker(): Promise<void> {\n for (;;) {\n const index = next\n next += 1\n if (index >= total) return\n await fn(items[index] as I, index)\n }\n }\n await Promise.all(Array.from({ length: workerCount }, () => worker()))\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{r as t}from"./apply-path-s-YeCUXP.mjs";var n=e({enumeratePaths:()=>r,getCanonicalPaths:()=>i});async function*r(e,n,r={}){let i=r.pageSize??200,a=r.publishConcurrency??5,{locale:o,entityType:s}=e,c=e.afterId;for(;;){let e={locale:o,isCanonical:!0};s!==void 0&&(e.entityType=s),c!==void 0&&(e.id={gt:c});let r=await n.pathsClient.findMany({where:e,orderBy:[{column:`id`,dir:`asc`}],limit:i});if(r.length===0)break;let l=new Map;for(let e of r){let t=l.get(e.entityType);t?t.push(e):l.set(e.entityType,[e])}let u=new Map;await t([...l.entries()],a,async([e,t])=>{let r=await n.filterPublished(e,t.map(e=>e.entityId),o);u.set(e,r)});for(let e of r)u.get(e.entityType)?.has(e.entityId)&&(yield{path:e.path,entityType:e.entityType,id:e.entityId,updatedAt:e.updatedAt});let d=r[r.length-1];if(!d||(c=d.id,r.length<i))break}}async function i(e,t,n,r){let i=new Map;for(let a=0;a<t.length;a+=500){let o=t.slice(a,a+500),s=await r.pathsClient.findMany({where:{entityType:e,locale:n,isCanonical:!0,entityId:{in:[...o]}},limit:o.length});for(let e of s)i.set(e.entityId,e.path)}return i}export{n,i as r,r as t};
2
- //# sourceMappingURL=enumerate-B5tWe9JC.mjs.map
@@ -1,2 +0,0 @@
1
- import{t as e}from"./versionable-Br_G5VGR.mjs";import{r as t}from"./errors-NRNILhhm.mjs";import{i as n,o as r,r as i}from"./routing-strategy-DQGPPBlW.mjs";import{n as a,t as o}from"./content-config-store-8omQF5p5.mjs";import{n as s,t as c}from"./registry-CaPbY5lm.mjs";import"./locale-utils-B0mFoV47.mjs";import{n as l}from"./chrome-entity-names-zYJzP-_U.mjs";import{r as u,t as d}from"./apply-path-s-YeCUXP.mjs";import{d as f,f as p,l as m,t as h,u as g}from"./schema-BPDn8ajz.mjs";import{r as _,t as v}from"./routes-DuJa-w4T.mjs";import{n as y}from"./locks-5779ugSW.mjs";import{MAX_PATH_DEPTH as b,hasPathParent as x,resolveEffectiveSlugMode as S,resolvePattern as C}from"@murumets-ee/entity";import{definePlugin as w,getApp as T,getContext as E,runAsCli as D}from"@murumets-ee/core";import{column as O,defineTable as k,schemaRegistry as A}from"@murumets-ee/db";import{asc as j,eq as M,inArray as N,sql as P}from"drizzle-orm";import{z as F}from"zod";import{createHash as I}from"node:crypto";const L=Symbol.for(`@murumets-ee/content:locale-status-writer`);async function R(e,t,n,r){let i=r===`published`?new Date:null;await e.upsert({entityId:t,locale:n,status:r,publishedAt:i},{target:[`entityId`,`locale`],set:{status:r,publishedAt:i}})}function z(e){globalThis[L]=(n,r)=>{let{localeStatus:i}=c(n.name);if(i)return async(n,a,o)=>{let s=E()?.availableLocales??e;if(!s.includes(a))throw new t(a,[...s]);await R(i.makeClient(r),n,a,o)}}}function B(){return{async writeDraft({documentType:e,documentId:t,locale:n,dataPatch:r,user:i,tx:a}){let{drafts:o}=c(e);if(!o)return!1;let s=o.makeClient(a),l=new Date;return await s.upsert({entityId:t,locale:n,data:r,createdBy:i.id,createdByName:i.name??null,createdAt:l,updatedAt:l},{target:[`entityId`,`locale`],set:{updatedAt:l},mergeJsonb:[`data`]}),!0},async readDraft({documentType:e,documentId:t,locale:n,db:r}){let{drafts:i}=c(e);if(!i)return null;let a=await i.makeClient(r).findOne({entityId:t,locale:n});return a?{data:a.data??{},createdBy:a.createdBy??null,createdByName:a.createdByName??null,updatedAt:a.updatedAt??null}:null},async readLocaleStatus({documentType:e,documentId:t,locale:n,db:r}){let{localeStatus:i}=c(e);if(!i)return null;let a=await i.makeClient(r).findOne({entityId:t,locale:n});return a?a.status===`published`?`published`:`draft`:null},async hasAnyDraft({documentType:e,documentId:t,db:n}){let{drafts:r}=c(e);return r?r.makeClient(n).exists({entityId:t}):!1},async hasAnyLocaleActivity({documentType:e,documentId:t,db:n}){let{localeStatus:r}=c(e);if(r&&await r.makeClient(n).exists({entityId:t}))return!0;let i=A.get(`${e}_translations`);if(i){let e=i.entityId;if(e!==void 0&&(await n.select({one:P`1`}).from(i).where(M(e,t)).limit(1)).length>0)return!0}return!1}}}function V(e){let{defaultLocale:t}=o();return e===`_`||e===t?{defaultLocale:t}:{locale:e,defaultLocale:t}}function H(e){let{defaultLocale:t}=o();return e===`_`||e===t?`_`:e}async function U(e,t,n,r){let{drafts:i}=c(e);if(!i)return;let a=await i.makeClient(r).findOne({entityId:t,locale:n});return a?a.data??void 0:void 0}function W(){return{async snapshotCurrentState({documentType:t,documentId:n,locale:r,user:i}){let a=T(),o=a.entities.get(t);if(!o)return!1;let s=e(o.behaviors);if(!s)return!1;let c=a.getClient(o),l=c.getDb(),u=H(r),d=await c.findById(n,V(u));if(!d)return!1;let f=await U(t,n,u,l),p=f?{...d,...f}:{...d},[{createContentContext:m},{createVersionSnapshot:h}]=await Promise.all([import(`./context-Bw0n6P47.mjs`).then(e=>e.t),import(`./versions-qld3n7B-.mjs`).then(e=>e.c)]);return await h(m({admin:c,entity:o},s),n,p,d,u,{id:i.id,...i.name==null?{}:{name:i.name}}),!0}}}const G=Symbol.for(`@murumets-ee/content:path-writers-factory`),K=Symbol.for(`@murumets-ee/content:cascade-trigger-factory`);function q(e){globalThis[K]=e}function J(){let e=globalThis[K];return typeof e==`function`?e:void 0}function Y(e,t){let n=g.makeClient(e.db.readWrite);return async r=>{if(r.action===`delete`){await n.deleteMany({entityType:r.entityName,entityId:r.id});return}if(r.action===`release`){await n.deleteMany({entityType:r.entityName,entityId:r.id,isCanonical:!0});return}let i=o(),a=i.defaultLocale,s=i.reservedPathPrefixes??[],c=r.locale?[r.locale]:i.locales.map(e=>e.code),l=r.viaCascade===!0?e.getSystemClient(t):e.getClient(t),u=async n=>{if(!x(t))return;let i=J();if(i)try{await i(e,t,r.id,n)}catch(t){e.logger.warn({err:t,entity:r.entityName,id:r.id,locale:n},`toolkit_paths: descendant-cascade trigger threw — the entity write already committed, so tolerating and continuing (paths are rebuildable, D005)`)}};for(let i of c){let o=i===r.locale?r.row??null:await l.findById(r.id,{locale:i,defaultLocale:a});if(!o){await d({pathsClient:n,entityType:r.entityName,entityId:r.id,locale:i,path:null,skipReason:`row-unreadable`,reservedPathPrefixes:s,logger:e.logger});continue}let c=await f({entity:t,row:o,locale:i,defaultLocale:a,pathsClient:n}),p=await d({pathsClient:n,entityType:r.entityName,entityId:r.id,locale:i,path:c.path,...c.skipReason!==void 0&&{skipReason:c.skipReason},reservedPathPrefixes:s,logger:e.logger});(p.outcome===`renamed`||r.action===`restore`&&p.outcome===`created`)&&await u(i)}}}function X(e,t){let n=g.makeClient(e.db.readWrite);return async r=>{let i=o(),a=i.defaultLocale,s=i.reservedPathPrefixes??[],c=r.locale??a,l=null;r.id!==void 0&&(l=await e.getClient(t).findById(r.id,{locale:c,defaultLocale:a}));let u=S(r.data.slugMode,l?.slugMode);await p({entity:t,data:r.data,currentRow:l,id:r.id,locale:c,defaultLocale:a,slugMode:u,reservedPathPrefixes:s,pathsClient:n})}}function ee(){globalThis[G]=(e,t)=>({pathIndexWriter:Y(e,t),pathConflictGuard:X(e,t)})}const Z=F.object({entityType:F.string().min(1),parentId:F.string().uuid(),locale:F.string().min(1)}),Q=F.object({entity:F.string().min(1).optional(),locale:F.string().min(1).optional(),dryRun:F.boolean()});async function te(e,t,n={}){let{entityType:r,parentId:i,locale:a}=t,s=n.maxItems??2e3,c=n.concurrency??5,l={processed:0,regenerated:0,unchanged:0,skipped:0,failed:0,truncated:!1},p=e=>({phase:e,...l}),m=()=>({entityType:r,parentId:i,locale:a,cap:s,...l}),h=e.entities.get(r);if(!h)return e.logger.warn({entityType:r,parentId:i,locale:a},`paths-cascade: unknown entity type — nothing to regenerate`),n.onProgress?.(p(`done`)),m();if(!x(h))return e.logger.warn({entityType:r,parentId:i,locale:a},`paths-cascade: entity is not a pathParent() hierarchy — nothing to regenerate`),n.onProgress?.(p(`done`)),m();let _=o(),v=_.defaultLocale,y=_.reservedPathPrefixes??[],b=g.makeClient(e.db.readWrite),S=e.getClient(h),C=S.getTable().parent,w=async t=>{let n=t.id;try{let i=await f({entity:h,row:t,locale:a,defaultLocale:v,pathsClient:b});switch((await d({pathsClient:b,entityType:r,entityId:n,locale:a,path:i.path,...i.skipReason!==void 0&&{skipReason:i.skipReason},reservedPathPrefixes:y,logger:e.logger})).outcome){case`created`:case`renamed`:l.regenerated+=1;break;case`unchanged`:l.unchanged+=1;break;default:l.skipped+=1;break}}catch(t){l.failed+=1,e.logger.warn({err:t,entityType:r,id:n,locale:a},`paths-cascade: failed to regenerate one descendant path — continuing (per-item isolation)`)}},T=[i];for(;T.length>0;){let e=s-l.processed;if(e<=0){l.truncated=!0;break}let t=await S.findMany({where:N(C,T),locale:a,defaultLocale:v,limit:e+1});if(t.length===0)break;let r=t;if(t.length>e&&(r=t.slice(0,e),l.truncated=!0),await u(r,c,w),l.processed+=r.length,n.onProgress?.(p(`walking`)),l.truncated)break;T=r.map(e=>e.id)}return l.truncated&&e.logger.warn({entityType:r,parentId:i,locale:a,processed:l.processed,cap:s},`paths-cascade: descendant subtree exceeded the cap — deeper/remaining descendants were NOT regenerated. Run \`lumi paths:rebuild --entity ${r}\` for guaranteed full coverage.`),n.onProgress?.(p(`done`)),m()}async function ne(e,t,n={}){let r=n.pageSize??200,i=n.concurrency??5,a=n.sampleCap??25,s=t.dryRun,c=o(),l=c.defaultLocale,p=c.reservedPathPrefixes??[],m=c.locales.map(e=>e.code),h=t.locale?m.filter(e=>e===t.locale):m,_=[...e.entities.values()].filter(e=>!!e.frontend?.path&&(t.entity===void 0||e.name===t.entity)),v=t.entity!==void 0&&_.length===0,y=t.locale!==void 0&&h.length===0;v&&e.logger.warn({entity:t.entity,dryRun:s},`paths-rebuild: --entity matches no routable entity (one declaring frontend.path) — nothing to rebuild (likely a typo in the entity name)`),y&&e.logger.warn({locale:t.locale,configuredLocales:m,dryRun:s},`paths-rebuild: --locale matches no configured locale — nothing to rebuild (likely a typo in the locale code)`);let S=v||y?{entity:v,locale:y}:void 0,C=_.some(e=>x(e)),w=s&&C?`Preview reflects a SINGLE pass; a pathParent() hierarchy drifted at multiple levels may need the real (non-dry-run) rebuild's multi-pass convergence to fully resolve. The real run always converges.`:void 0,T=g.makeClient(e.db.readWrite),E={scanned:0,changed:0,applied:0,failed:0},D=new Map,O={missing:0,conflicts:0,unchanged:0,skipped:0},k=(e,t)=>{let n=D.get(e);n!==t&&(n!==void 0&&n!==`resolved`&&--O[n],D.set(e,t),t!==`resolved`&&(O[t]+=1))},A=[],M=!1,N=e=>{A.length<a?A.push(e):M=!0},P=0,F=e=>({phase:e,dryRun:s,scope:{entity:t.entity??null,locale:t.locale??null},scanned:E.scanned,changed:E.changed,applied:E.applied,failed:E.failed,missing:O.missing,conflicts:O.conflicts,unchanged:O.unchanged,skipped:O.skipped,passes:P,sample:A,sampleTruncated:M,...w===void 0?{}:{hierarchyNote:w},...S===void 0?{}:{unmatchedScope:S}}),I=async(t,n,r,i)=>{let a=r.id,o=!1;try{let c=await f({entity:t,row:r,locale:i,defaultLocale:l,pathsClient:T}),u=await d({pathsClient:T,entityType:n,entityId:a,locale:i,path:c.path,...c.skipReason!==void 0&&{skipReason:c.skipReason},suppressNoteworthy:[`parent-not-indexed`],reservedPathPrefixes:p,logger:e.logger,dryRun:s}),m=`${n}:${a}:${i}`;switch(u.outcome){case`unchanged`:k(m,`unchanged`);break;case`skipped-null`:case`skipped-reserved`:k(m,`skipped`);break;case`conflict`:k(m,`conflicts`),N({kind:`conflict`,entityType:n,entityId:a,locale:i,oldPath:u.oldPath,newPath:u.newPath,...u.conflictWith?{conflictWith:u.conflictWith.entityId}:{}});break;case`created`:s?k(m,`missing`):(E.applied+=1,o=!0,k(m,`resolved`)),N({kind:`missing`,entityType:n,entityId:a,locale:i,oldPath:null,newPath:u.newPath});break;case`renamed`:E.changed+=1,s||(E.applied+=1,o=!0),k(m,`resolved`),N({kind:`changed`,entityType:n,entityId:a,locale:i,oldPath:u.oldPath,newPath:u.newPath});break}}catch(t){E.failed+=1,e.logger.warn({err:t,entityType:n,id:a,locale:i},`paths-rebuild: failed to process one document — continuing (per-item isolation)`)}return o},L=async t=>{let a=0;for(let o of t){let t=e.getClient(o),s=o.name,c=t.getTable().id;for(let e of h){let d;for(;;){let f=await t.findMany({locale:e,defaultLocale:l,limit:r,orderBy:j(c),...d===void 0?{}:{cursor:{field:`id`,value:d,direction:`asc`}}});if(f.length===0)break;await u(f,i,async t=>{await I(o,s,t,e)&&(a+=1)}),E.scanned+=f.length;let p=f[f.length-1];if(p&&(d=p.id),n.onProgress?.(F(`scanning`)),f.length<r)break}}}return a},R=async t=>{let r=[];for(let[n,i]of D){if(i!==`conflicts`&&i!==`skipped`&&i!==`missing`)continue;let a=n.indexOf(`:`),o=n.lastIndexOf(`:`);if(a<=0||o<=a)continue;let s=n.slice(0,a);if(t.has(s))continue;let c=e.entities.get(s);c!==void 0&&r.push({entity:c,entityType:s,entityId:n.slice(a+1,o),locale:n.slice(o+1)})}if(r.length===0)return 0;let a=0;return await u(r,i,async t=>{let n=await e.getClient(t.entity).findById(t.entityId,{locale:t.locale,defaultLocale:l});n!==null&&(E.scanned+=1,await I(t.entity,t.entityType,n,t.locale)&&(a+=1))}),n.onProgress?.(F(`scanning`)),a},z=_.filter(e=>x(e)),B=new Set(z.map(e=>e.name)),V=s||!C?1:b;for(let e=0;e<V;e++){P=e+1;let t=await L(e===0?_:z);if(e>0&&(t+=await R(B)),t===0)break}M&&e.logger.warn({scanned:E.scanned,changed:E.changed,missing:O.missing,conflicts:O.conflicts,sampleCap:a},`paths-rebuild: more diff entries than the progress sample can hold — see the aggregate counts; the stored sample is truncated`);let H=F(`done`);n.onProgress?.(H);let{phase:U,...W}=H;return W}async function re(e){let{defineJob:t,registerJob:n,QueueClient:r}=await import(`@murumets-ee/queue/client`),i=t({name:`content:paths-cascade`,description:`Regenerate toolkit_paths canonical rows for the descendants of a renamed routable-hierarchy ancestor (one locale). Walks the full bounded subtree in a single invocation.`,schema:Z}),a=t({name:`content:paths-rebuild`,description:`Recompute every routable document's canonical toolkit_paths row (optionally scoped to one entity/locale); dryRun reports the diff without writing. On-demand (lumi paths:rebuild).`,schema:Q});n(i,async t=>{await D(async()=>{let n=await te(e,Z.parse(t.payload),{onProgress:e=>t.updateProgress(e)});e.logger.info(n,`paths-cascade: descendant regeneration complete`)})}),n(a,async t=>{await D(async()=>{let n=await ne(e,Q.parse(t.payload),{onProgress:e=>t.updateProgress(e)});e.logger.info({dryRun:n.dryRun,scanned:n.scanned,changed:n.changed,missing:n.missing,conflicts:n.conflicts,applied:n.applied,failed:n.failed,passes:n.passes,...n.hierarchyNote===void 0?{}:{hierarchyNote:n.hierarchyNote}},`paths-rebuild: sweep complete`)})}),q(async(e,t,n,a)=>{await new r({app:e,logger:e.logger}).enqueue(i,{entityType:t.name,parentId:n,locale:a})})}const $=k({name:`toolkit_path_patterns`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityType:O.varchar({length:100,notNull:!0,pgName:`entity_type`}),locale:O.varchar({length:10,notNull:!0}),patternHash:O.varchar({length:64,notNull:!0,pgName:`pattern_hash`}),updatedAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityType`,`locale`]}]}),ie=$.table;function ae(e,t,n){let r=C(e,t,n);if(r===null)return null;let i=typeof r==`function`?r.toString():JSON.stringify(r);return I(`sha256`).update(i).digest(`hex`)}async function oe(e){let t=o(),n=t.defaultLocale,r=t.locales.map(e=>e.code),i=$.makeClient(e.db.readWrite);for(let[t,a]of e.entities){let o=a.frontend?.path;if(o!==void 0)for(let a of r){let r=ae(o,a,n);if(r===null)continue;let s=await i.findOne({entityType:t,locale:a});s!==null&&s.patternHash===r||(s!==null&&e.logger.warn({entity:t,locale:a,previousHash:s.patternHash,currentHash:r},`toolkit_paths: frontend.path pattern for '${t}' (locale '${a}') changed since last boot — stale toolkit_paths rows may exist; run \`lumi paths:rebuild --entity ${t}\``),await i.upsert({entityType:t,locale:a,patternHash:r},{target:[`entityType`,`locale`],set:{patternHash:r,updatedAt:new Date}}))}}}const se=async(e,t,n)=>{if(t.length===0)return new Map;let[{buildEnumerateDeps:r},{getCanonicalPaths:i}]=await Promise.all([import(`./resolve-context-rr-Eanwp.mjs`).then(e=>e.l),import(`./enumerate-B5tWe9JC.mjs`).then(e=>e.n)]),a=r(T()),o=await a.filterPublished(e,t,n);return o.size===0?new Map:i(e,[...o],n,a)};function ce(e){return k({name:`${e.name}_drafts`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityId:O.uuid({notNull:!0,pgName:`entity_id`}),locale:O.varchar({length:10,notNull:!0,default:`_`}),data:O.jsonb({notNull:!0}),createdBy:O.varchar({length:255,notNull:!0,pgName:`created_by`}),createdByName:O.varchar({length:255,pgName:`created_by_name`}),createdAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityId`,`locale`]}]})}function le(e){return k({name:`${e.name}_locale_status`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityId:O.uuid({notNull:!0,pgName:`entity_id`}),locale:O.varchar({length:10,notNull:!0}),status:O.varchar({length:20,notNull:!0,default:`draft`}),publishedAt:O.timestamp({withTimezone:!0,pgName:`published_at`})},unique:[{on:[`entityId`,`locale`]}]})}function ue(e){return k({name:`${e.name}_versions`,columns:{id:O.uuid({primaryKey:!0,defaultRandom:!0}),entityId:O.uuid({notNull:!0,pgName:`entity_id`}),version:O.integer({notNull:!0}),locale:O.varchar({length:10,notNull:!0,default:`_`}),data:O.jsonb({notNull:!0}),delta:O.jsonb(),status:O.varchar({length:20}),createdBy:O.varchar({length:255,pgName:`created_by`}),createdByName:O.varchar({length:255,pgName:`created_by_name`}),createdAt:O.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),isAutosave:O.boolean({notNull:!0,default:!1,pgName:`is_autosave`})},unique:[{on:[`entityId`,`version`,`locale`]}]})}function de(e){if(!e.locales||e.locales.length===0)throw Error(`content() requires at least one locale`);if(!e.defaultLocale)throw Error(`content() requires a defaultLocale`);if(!e.locales.some(t=>t.code===e.defaultLocale))throw Error(`defaultLocale "${e.defaultLocale}" not found in locales: [${e.locales.map(e=>e.code).join(`, `)}]`);if(e.reservedPathPrefixes){for(let t of e.reservedPathPrefixes)if(typeof t!=`string`||!t.startsWith(`/`))throw Error(`content(): reservedPathPrefixes entries must be absolute paths starting with '/', got ${JSON.stringify(t)}`)}let t=e.autoTrash??!0,o=t===!1?void 0:{publishable:!0,...typeof t==`object`&&t.exclude?{exclude:t.exclude}:{}};return w({name:`@murumets-ee/content`,contributes:{"blocks.documentDraftStore":B(),"blocks.documentVersionStore":W(),"blocks.publishedPathResolver":se,...o?{"trash.autoEnable":o}:{},...e.reservedPathPrefixes?{"routing.reservedPathPrefixes":e.reservedPathPrefixes}:{}},shared:{settings:[r],features:[{resource:`redirect`,actions:[`manage`],defaultRoles:[`admin`],description:`Create, edit, and delete manual URL redirects (toolkit_redirects).`},{resource:`path`,actions:[`resolve`],defaultRoles:[`admin`,`editor`],description:`Resolve a site URL path to the CMS document that serves it.`}]},server:{entities:[n,i],tables:{toolkitContentLocks:y,toolkitPaths:m,toolkitPathPatterns:ie,toolkitRedirects:h},routes:[...v(),..._()],seeders:[{name:`content:site-chrome`,description:`Create the empty site_chrome row for each placement (header, footer), as a DRAFT, so they can be edited on the page canvas. Publishing one is what makes /v1/_chrome serve it instead of the delivery template's own fallback. Idempotent — an existing row for a placement is left alone.`,available:l,bootstrap:!0,run:async()=>{let{seedSiteChrome:e}=await import(`@murumets-ee/content/seed`);return await e()}}],init:async t=>{a(e);let n=0,r=0,i=0;for(let[e,a]of t.entities){if(a.behaviors?.some(e=>e.name===`versionable`)){let t=`${e}_versions`;if(!A.has(t)){let n=ue(a);A.register(t,n.table),s(e,{versions:n})}n++}let t=a.behaviors?.some(e=>e.name===`publishable`),o=a.allFields,c=Object.values(o).some(e=>e.translatable);if(t&&c){let t=`${e}_locale_status`;if(!A.has(t)){let n=le(a);A.register(t,n.table),s(e,{localeStatus:n})}r++}if(t){let t=`${e}_drafts`;if(!A.has(t)){let n=ce(a);A.register(t,n.table),s(e,{drafts:n})}i++}}z(e.locales.map(e=>e.code)),ee();try{await re(t)}catch(e){t.logger.warn({err:e},"content: failed to register routing path jobs — descendant cascade + `lumi paths:rebuild` will be unavailable (path writes still succeed)")}t.logger.info({locales:e.locales.map(e=>e.code),defaultLocale:e.defaultLocale,versionableEntities:n,localeStatusEntities:r,draftEntities:i},`Content plugin initialized`)},onReady:async e=>{try{await oe(e)}catch(t){e.logger.error({err:t},"content: boot-time frontend-path pattern-drift check failed — continuing app boot without it. Inspect the logged `err` for the cause: a transient DB error can be retried (and `lumi paths:rebuild` re-derives the index if a pattern actually changed), whereas a structural cause — e.g. a missing migration leaving `toolkit_path_patterns` absent — will recur on every boot and needs `lumi migrate`")}}}})}export{R as n,de as t};
2
- //# sourceMappingURL=plugin-DEYaEeWC.mjs.map
@@ -1,2 +0,0 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{t}from"./content-config-store-8omQF5p5.mjs";import{i as n,n as r,u as i}from"./schema-BPDn8ajz.mjs";import{createSystemReachResolver as a}from"@murumets-ee/core";import{QueryClient as o}from"@murumets-ee/entity/query";import{inArray as s}from"drizzle-orm";import{z as c}from"zod";import{DEFAULT_LOCALE as l,GLOBAL_SCOPE_ID as u}from"@murumets-ee/settings";import{toolkitSettingsTable as d}from"@murumets-ee/settings/schema";const f=c.object({entity:c.string().min(1),id:c.string().uuid()});function p(e){let t=f.safeParse(e);return t.success?t.data:null}var m=e({buildCanonicalPathDeps:()=>g,buildCanonicalPathsBatchDeps:()=>b,buildEnumerateDeps:()=>y,buildPublishChecker:()=>_,buildRedirectsListDeps:()=>C,buildResolveDeps:()=>w,buildSitemapDeps:()=>S,createHomepageReader:()=>v});const h=a(`path-resolver`);function g(e){return{pathsClient:i.makeClient(e.db.readOnly)}}function _(e){return n({entityResolver:t=>e.entities.get(t),queryClientFactory:t=>new o({entity:t,db:e.db.readOnly,logger:e.logger.child({resolve:`publish-check`,entity:t.name}),contextResolver:h})})}function v(e){let t=d.makeClient(e);return async()=>{let e=await t.findOne({namespace:`site`,key:`homeDocument`,scope:`global`,scopeId:u,locale:l});return e?p(e.value):null}}function y(e){let t=e.db.readOnly;return{pathsClient:i.makeClient(t),filterPublished:async(n,r,i)=>{if(r.length===0)return new Set;let a=e.entities.get(n);if(a===void 0)return new Set;let c=new o({entity:a,db:t,logger:e.logger.child({resolve:`enumerate-publish`,entity:a.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:s(l,[...r]),locale:i}),d=new Set;for(let e of u){let t=e.id;typeof t==`string`&&d.add(t)}return d}}}function b(e){return{pathsClient:i.makeClient(e.db.readOnly)}}function x(e){let t=e.db.readOnly;return async(n,r,i)=>{if(r.length===0)return new Set;let a=e.entities.get(n);if(a===void 0||!a.behaviors?.some(e=>e.name===`seoMeta`))return new Set;let c=new o({entity:a,db:t,logger:e.logger.child({resolve:`sitemap-noindex`,entity:a.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:s(l,[...r]),locale:i}),d=new Set;for(let e of u){let t=e.id,n=e.noIndex;typeof t==`string`&&n===!0&&d.add(t)}return d}}function S(e){return{enumerate:y(e),canonicalBatch:b(e),filterNoIndex:x(e)}}function C(e){let t=y(e);return{redirectsClient:r.makeClient(e.db.readOnly),pathsClient:t.pathsClient,canonicalBatch:b(e),filterPublished:t.filterPublished}}function w(e){let n=e.db.readOnly;return{pathsClient:i.makeClient(n),redirectsClient:r.makeClient(n),publishChecker:_(e),homepageReader:v(n),locales:t().locales.map(e=>e.code),logger:e.logger}}export{C as a,v as c,p as d,_ as i,m as l,b as n,w as o,y as r,S as s,g as t,f as u};
2
- //# sourceMappingURL=resolve-context-rr-Eanwp.mjs.map
@@ -1,2 +0,0 @@
1
- import{t as e}from"./content-config-store-8omQF5p5.mjs";import{n as t}from"./apply-path-s-YeCUXP.mjs";import{n,p as r,s as i,u as a}from"./schema-BPDn8ajz.mjs";import{matchReservedPrefix as o}from"@murumets-ee/entity";import{combineAdminRoutes as s,defineAdminRoute as c,getContext as l,safeAudit as u}from"@murumets-ee/core";import{and as d,eq as f}from"drizzle-orm";import{z as p}from"zod";import{entityRefs as m}from"@murumets-ee/entity/refs";const h=`path:resolve`,ee=[`admin`,`editor`];function g(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const _={kind:`none`};function v(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 y(t,n){let r=e(),i=t??n??r.defaultLocale;return(l()?.availableLocales??r.locales.map(e=>e.code)).includes(i)?{locale:i}:{error:`query param 'locale' is not a content locale`}}const b=async(e,t)=>{let n=new URL(e.url).searchParams,r=v(n.get(`path`));if(`error`in r)return g({error:r.error},400);let a=y(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in a)return g({error:a.error},400);let{locale:o}=a,{buildResolveDeps:s}=await import(`./resolve-context-rr-Eanwp.mjs`).then(e=>e.l),c=s(t.app),l=(e,n)=>t.checkPermission(e,`view`)?{kind:`document`,entityType:e,id:n}:_,u=await i(r.path,o,c);if(u.kind===`document`)return g(l(u.entityType,u.id));if(u.kind===`none`)return g(_);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return g({kind:`redirect`,to:u.to});let d=await i(u.to,o,c);return g(d.kind===`document`?l(d.entityType,d.id):_)};function x(){return s([c({prefix:`paths`,path:`resolve`,method:`GET`,permission:h,defaultRoles:ee,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,handler:b})])}var S=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}},C=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}},w=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}},T=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}},E=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 D=`toolkit_redirects`;async function O(e,t,n){n.kind===`entity`&&await e.insert(m).values({sourceEntity:D,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function k(e,t){await e.delete(m).where(d(f(m.sourceEntity,D),f(m.sourceId,t)))}const A=p.discriminatedUnion(`kind`,[p.object({kind:p.literal(`entity`),entity:p.string().min(1),id:p.string().uuid()}),p.object({kind:p.literal(`url`),url:p.string().max(2048).url()}),p.object({kind:p.literal(`path`),path:p.string().min(1).max(2048).startsWith(`/`)})]);function j(e){return r(e)}function te(e){let t=j(e.fromPath),n=o(t,e.reservedPathPrefixes);if(n!==void 0)throw new S(t,n)}async function ne(e){let t=j(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new C(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new C(t,null,n.locale)}async function M(e){if(e.target.kind!==`path`)return e.target;let t=j(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 N(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new w(t);if(await r.findById(n)==null)throw new T(t,n)}const P=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,F=/^[a-zA-Z0-9:_.-]{1,100}$/,I=`redirect:manage`,L=[`admin`];function R(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function z(e,t){return R({error:e},t)}async function B(e,t){let n;try{n=await e.json()}catch{return{response:z(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:z(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function V(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function H(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function U(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function W(){return(await U()).db.readWrite}async function G(){let e=await U();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function K(e){return e}const q=p.union([p.literal(301),p.literal(302)]),J=p.string().min(1).max(2048).startsWith(`/`),Y=p.object({locale:p.string().min(1).nullable(),fromPath:J,target:A,status:q.default(301)}),X=p.object({locale:p.string().min(1).nullable().optional(),fromPath:J.optional(),target:A.optional(),status:q.optional()});function Z(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:z(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Q(e){if(e instanceof S)return R({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof C)return R({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof E)return R({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof w)return R({error:e.message,entity:e.entity},400);if(e instanceof T)return R({error:e.message,entity:e.entity,id:e.id},400);if(t(e))return R({error:`A redirect already exists for this locale + source path.`},409);throw e}async function $(t){let r=e(),i=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=j(t.fromPath);return te({fromPath:s,reservedPathPrefixes:i}),await N({target:t.target,resolveClient:t.resolveClient}),(await W()).transaction(async e=>{let r=K(e),i=n.makeClient(r),c=a.makeClient(r);await ne({fromPath:s,locale:t.locale,configuredLocales:o,pathsClient:c});let l=await M({target:t.target,locale:t.locale,redirectsClient:i,...t.id===null?{}:{excludeId:t.id}});if(l.kind===`path`&&j(l.path)===s)throw new E(s,t.locale);if(t.id===null){let e=await i.insert({locale:t.locale,fromPath:s,target:l,status:t.status,createdBy:t.userId});return await O(r,e.id,l),e}let u=await i.update({id:t.id},{locale:t.locale,fromPath:s,target:l,status:t.status,updatedAt:new Date});return u?(await k(r,t.id),await O(r,t.id,l),u):null})}const re=async e=>{let t=new URL(e.url),r=t.searchParams.get(`locale`),i=Math.min(Math.max(V(t.searchParams.get(`limit`),50),1),200),a=Math.min(V(t.searchParams.get(`offset`),0),1e5),o;if(r!==null&&r!==``){let e=Z(r);if(e)return e;o=r}let s=n.makeClient(await W());return R({items:o===void 0?await s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:i,offset:a}):await s.findMany({where:{locale:o},orderBy:[{column:`createdAt`,dir:`desc`}],limit:i,offset:a}),total:o===void 0?await s.count():await s.count({locale:o})})},ie=async(e,t)=>{let n=await B(e,Y);if(`response`in n)return n.response;let r=n.data,i=Z(r.locale);if(i)return i;try{let e=await G(),n=await $({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(u(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...H(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),R({item:n},201)):z(`Failed to create redirect`,500)}catch(e){return Q(e)}},ae=async(e,t)=>{let r=t.segments[0];if(!r||!P.test(r))return z(`Invalid id`,400);let i=await B(e,X);if(`response`in i)return i.response;let a=i.data,o=await n.makeClient(await W()).findOne({id:r});if(!o)return z(`Not found`,404);let s=a.locale===void 0?o.locale:a.locale,c=a.fromPath===void 0?o.fromPath:a.fromPath,l=a.target===void 0?o.target:a.target,d=a.status===void 0?o.status:a.status,f=Z(s);if(f)return f;try{let e=await G(),n=await $({id:r,locale:s,fromPath:c,target:l,status:d,userId:t.user.id,resolveClient:e});return n?(u(t,{action:`redirect.updated`,entityType:`redirect`,entityId:n.id,...H(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),R({item:n})):z(`Not found`,404)}catch(e){return Q(e)}},oe=async(e,t)=>{let r=t.segments[0];if(!r||!P.test(r))return z(`Invalid id`,400);let i=await(await W()).transaction(async e=>{let t=K(e);return await k(t,r),n.makeClient(t).delete({id:r})});return i?(u(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:r,...H(t.user),metadata:{locale:i.locale,fromPath:i.fromPath}}),R({ok:!0})):z(`Not found`,404)},se=async e=>{let t=new URL(e.url),n=t.searchParams.get(`entityType`),r=t.searchParams.get(`entityId`);if(!n||!F.test(n))return z(`entityType is required`,400);if(!r||!P.test(r))return z(`entityId is required and must be a UUID`,400);let i=t.searchParams.get(`locale`),o;if(i!==null&&i!==``){let e=Z(i);if(e)return e;o=i}return R({items:await a.makeClient(await W()).findMany({where:{entityType:n,entityId:r,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function ce(){return s([c({prefix:`redirects`,path:``,method:`GET`,permission:I,defaultRoles:L,description:`List manual URL redirects (paginated, optional locale filter).`,handler:re}),c({prefix:`redirects`,path:`history`,method:`GET`,permission:I,defaultRoles:L,description:`Read-only automatic-redirect (rename) history for one document.`,handler:se}),c({prefix:`redirects`,path:``,method:`POST`,permission:I,defaultRoles:L,description:`Create a manual URL redirect.`,handler:ie}),c({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Update a manual URL redirect by id.`,handler:ae}),c({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Delete a manual URL redirect by id.`,handler:oe})])}export{h as n,x as r,ce as t};
2
- //# sourceMappingURL=routes-DuJa-w4T.mjs.map
@@ -1,2 +0,0 @@
1
- import{buildEntityHref as e,matchReservedPrefix as t,resolvePattern as n}from"@murumets-ee/entity";import{column as r,defineTable as i}from"@murumets-ee/db";import{sql as a}from"drizzle-orm";var o=class extends Error{entityName;locale;path;conflictingEntityId;constructor(e,t,n,r,i){super(i??`Path "${n}" (locale "${t}") is already used by another ${e} document. Choose a different slug, or set this document's slug manually.`),this.name=`PathConflictError`,this.entityName=e,this.locale=t,this.path=n,this.conflictingEntityId=r}},s=class extends Error{entityName;path;prefix;constructor(e,t,n){super(`Path "${t}" falls under the reserved prefix "${n}" and cannot be used as a public URL for a ${e} document.`),this.name=`PathReservedPrefixError`,this.entityName=e,this.path=t,this.prefix=n}};async function c(t){let r=t.entity.frontend?.path;if(r===void 0)return{path:null,skipReason:`not-routable`};let i=n(r,t.locale,t.defaultLocale);if(i===null)return{path:null,skipReason:`no-pattern-for-locale`};let a=i;if(typeof i==`string`&&i.includes(`[parent.path]`)){let e=t.row.parent;if(typeof e==`string`&&e.length>0){let n=await t.pathsClient.findOne({entityType:t.entity.name,entityId:e,locale:t.locale,isCanonical:!0});if(!n)return{path:null,skipReason:`parent-not-indexed`};a=i.replaceAll(`[parent.path]`,n.path)}else a=i.replaceAll(`[parent.path]`,``)}let o=e(a,t.row,t.locale,t.defaultLocale);return o===null?{path:null,skipReason:`unresolved-token`}:{path:l(o)}}function l(e){let t=e.replace(/\/{2,}/g,`/`);return t.length>1&&t.endsWith(`/`)?t.slice(0,-1):t}async function u(e){let{entity:n,data:r,currentRow:i,id:a,locale:l,defaultLocale:u,slugMode:f,reservedPathPrefixes:p,pathsClient:m}=e,h=``;for(let e=0;e<50;e++){let e={...i??{},...r},g=await c({entity:n,row:e,locale:l,defaultLocale:u,pathsClient:m});if(g.path===null)return;let _=g.path;h=_;let v=t(_,p);if(v!==void 0)throw new s(n.name,_,v);let y=await m.findOne({locale:l,path:_,isCanonical:!0}),b=y!=null&&y.entityType===n.name&&y.entityId===a;if(y==null||b)return;if(f===`manual`)throw new o(n.name,l,_,y.entityId);let x=e.slug;if(typeof x!=`string`||x.length===0)throw new o(n.name,l,_,y.entityId);r.slug=d(x)}throw new o(n.name,l,h,void 0,`Could not find a free path for a ${n.name} document near "${h}" (locale "${l}") after 50 attempts — choose a different slug manually.`)}function d(e){let t=/^(.*)-(\d+)$/.exec(e);return t?`${t[1]??e}-${Number.parseInt(t[2]??`1`,10)+1}`:`${e}-2`}const f=i({name:`toolkit_paths`,columns:{id:r.uuid({primaryKey:!0,defaultRandom:!0}),entityType:r.varchar({length:100,notNull:!0,pgName:`entity_type`}),entityId:r.uuid({notNull:!0,pgName:`entity_id`}),locale:r.varchar({length:10,notNull:!0}),path:r.text({notNull:!0}),isCanonical:r.boolean({notNull:!0,default:!0,pgName:`is_canonical`}),createdAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`path`],where:e=>a`${e.isCanonical} = true`},{on:[`entityType`,`entityId`,`locale`],where:e=>a`${e.isCanonical} = true`}],indexes:[{on:[`entityType`,`entityId`,`locale`]}]}),p=f.table;async function m(e){let{pathsClient:t,locale:n,path:r,logger:i}=e,a=await t.findMany({where:{locale:n,path:r},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:10});for(let e of a){let r=await t.findOne({entityType:e.entityType,entityId:e.entityId,locale:n,isCanonical:!0});if(r)return{entityType:e.entityType,entityId:e.entityId,currentPath:r.path}}return a.length===10&&i?.warn?.({locale:n,path:r,examined:a.length},`toolkit_paths: redirect lookup examined the full candidate window without resolving one — answering 404, but a further candidate may exist`),null}const h=5;async function g(e,t,n,r){return(await r.pathsClient.findOne({entityType:e,entityId:t,locale:n,isCanonical:!0}))?.path??null}function _(e){return{async isPublished(t,n,r){let i=e.entityResolver(t);return i===void 0?!1:await e.queryClientFactory(i).findById(n,{locale:r})!=null}}}function v(e,t,n){return b(e,t,n,(e,t,r)=>n.publishChecker.isPublished(e,t,r))}function y(e,t,n){return b(e,t,n,async()=>!0)}async function b(e,t,n,r){let i=l(e),a=await n.homepageReader();if(i===`/`)return a===null||!await r(a.entity,a.id,t)?{kind:`none`}:{kind:`document`,entityType:a.entity,id:a.id};let o=await n.pathsClient.findOne({locale:t,path:i,isCanonical:!0});if(o)return await r(o.entityType,o.entityId,t)?w(a,o.entityType,o.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`document`,entityType:o.entityType,id:o.entityId}:{kind:`none`};let s=await x(i,t,n,a);if(s!==null)return s;let c=await m({pathsClient:n.pathsClient,locale:t,path:i,...n.logger!==void 0&&{logger:n.logger}});return c===null||!await r(c.entityType,c.entityId,t)?{kind:`none`}:w(a,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:c.currentPath,status:301}}async function x(e,t,n,r){let i=await S(n.redirectsClient,e,t);if(i===null)return null;for(let e=0;e<5;e++){let e=i.target,a=T(i.status);if(e.kind===`url`)return{kind:`redirect`,to:e.url,status:a};if(e.kind===`entity`){if(w(r,e.entity,e.id))return{kind:`redirect`,to:`/`,status:a};let i=await g(e.entity,e.id,t,n);return i===null?{kind:`none`}:{kind:`redirect`,to:i,status:a}}let o=l(e.path),s=await S(n.redirectsClient,o,t);if(s!==null){i=s;continue}let c=await n.pathsClient.findOne({locale:t,path:o,isCanonical:!0});return c?w(r,c.entityType,c.entityId)?{kind:`redirect`,to:`/`,status:301}:{kind:`redirect`,to:o,status:a}:{kind:`none`}}return{kind:`none`}}async function S(e,t,n){return await e.findOne({fromPath:t,locale:n})||e.findOne({fromPath:t,locale:null})}async function C(e,t,n){let r=[];for(let i of n.locales){let a=await g(e,t,i,n);a!==null&&await n.publishChecker.isPublished(e,t,i)&&r.push({locale:i,path:a})}return r}function w(e,t,n){return e!==null&&e.entity===t&&e.id===n}function T(e){return e===302?302:301}const E=i({name:`toolkit_redirects`,columns:{id:r.uuid({primaryKey:!0,defaultRandom:!0}),locale:r.varchar({length:10}),fromPath:r.text({notNull:!0,pgName:`from_path`}),target:r.jsonb({notNull:!0}),status:r.integer({notNull:!0,default:301}),createdBy:r.varchar({length:255,pgName:`created_by`}),createdAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:r.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`locale`,`fromPath`],where:e=>a`${e.locale} IS NOT NULL`},{on:[`fromPath`],where:e=>a`${e.locale} IS NULL`}]}),D=E.table;export{C as a,v as c,c as d,u as f,_ as i,p as l,E as n,g as o,l as p,h as r,y as s,D as t,f as u};
2
- //# sourceMappingURL=schema-BPDn8ajz.mjs.map