@murumets-ee/content 0.109.0 → 0.111.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.d.mts +0 -1
- package/dist/block-resolver.d.mts +0 -1
- package/dist/block-resolver.mjs +0 -1
- package/dist/breadcrumbs-BqSuMNac.mjs +0 -1
- package/dist/chrome-entity-names-zYJzP-_U.mjs +0 -1
- package/dist/chrome-provider.d.mts +0 -1
- package/dist/chrome-provider.mjs +0 -1
- package/dist/client.d.mts +0 -1
- package/dist/client.mjs +0 -1
- package/dist/content-config-store-8omQF5p5.mjs +0 -1
- package/dist/context-D7eEItCY.mjs +0 -1
- package/dist/detail-template-names-CD1Y9n1U.mjs +0 -1
- package/dist/detail-template-provider.d.mts +0 -1
- package/dist/detail-template-provider.mjs +0 -1
- package/dist/document-site-B8tmEqLw.mjs +0 -1
- package/dist/entities/index.d.mts +0 -1
- package/dist/entities/index.mjs +0 -1
- package/dist/enumerate-CYHJKQz6.mjs +0 -1
- package/dist/errors-NRNILhhm.mjs +0 -1
- package/dist/index-cT8Qk4gu.d.mts +0 -1
- package/dist/index.mjs +0 -1
- package/dist/locale-entity-CoSq3C77.mjs +0 -1
- package/dist/locale-registry-COsJrVZl.mjs +0 -1
- package/dist/locale-utils-B0mFoV47.mjs +0 -1
- package/dist/lock-service.d.mts +0 -1
- package/dist/lock-service.mjs +0 -1
- package/dist/locks-5779ugSW.mjs +0 -1
- package/dist/plugin-CjgqBSFy.mjs +0 -1
- package/dist/plugin.d.mts +0 -1
- package/dist/query-client.d.mts +0 -1
- package/dist/query-client.mjs +0 -1
- package/dist/registry-CaPbY5lm.mjs +0 -1
- package/dist/resolve-context-DklN53BH.mjs +0 -1
- package/dist/resolve.d.mts +0 -1
- package/dist/resolve.mjs +0 -1
- package/dist/routes-siXmjKDA.mjs +0 -1
- package/dist/routing-strategy-CAG3tZP0.d.mts +0 -1
- package/dist/routing-strategy-D6B4ngT9.mjs +0 -1
- package/dist/schema-BYrYzsmO.mjs +0 -1
- package/dist/schema-LlK6EkOJ.mjs +0 -1
- package/dist/seed.d.mts +0 -1
- package/dist/seed.mjs +0 -1
- package/dist/site-chrome-BAtQaAqC.mjs +0 -1
- package/dist/site-chrome-DNCcePSR.d.mts +0 -1
- package/dist/translation-provenance-B41lEDM-.mjs +0 -1
- package/dist/translation-provenance-BZ0MlbFR.d.mts +0 -1
- package/dist/translation.d.mts +0 -1
- package/dist/translation.mjs +0 -1
- package/dist/types-BOk9tg_9.d.mts +0 -1
- package/dist/versionable-Br_G5VGR.mjs +0 -1
- package/dist/versionable-CRvV87nZ.d.mts +0 -1
- package/dist/versions-w2ui-Vzb.mjs +0 -1
- package/package.json +10 -10
- package/dist/admin.d.mts.map +0 -1
- package/dist/block-resolver.d.mts.map +0 -1
- package/dist/block-resolver.mjs.map +0 -1
- package/dist/breadcrumbs-BqSuMNac.mjs.map +0 -1
- package/dist/chrome-entity-names-zYJzP-_U.mjs.map +0 -1
- package/dist/chrome-provider.d.mts.map +0 -1
- package/dist/chrome-provider.mjs.map +0 -1
- package/dist/client.d.mts.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/content-config-store-8omQF5p5.mjs.map +0 -1
- package/dist/context-D7eEItCY.mjs.map +0 -1
- package/dist/detail-template-names-CD1Y9n1U.mjs.map +0 -1
- package/dist/detail-template-provider.d.mts.map +0 -1
- package/dist/detail-template-provider.mjs.map +0 -1
- package/dist/document-site-B8tmEqLw.mjs.map +0 -1
- package/dist/entities/index.d.mts.map +0 -1
- package/dist/entities/index.mjs.map +0 -1
- package/dist/enumerate-CYHJKQz6.mjs.map +0 -1
- package/dist/errors-NRNILhhm.mjs.map +0 -1
- package/dist/index-cT8Qk4gu.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/locale-entity-CoSq3C77.mjs.map +0 -1
- package/dist/locale-registry-COsJrVZl.mjs.map +0 -1
- package/dist/locale-utils-B0mFoV47.mjs.map +0 -1
- package/dist/lock-service.d.mts.map +0 -1
- package/dist/lock-service.mjs.map +0 -1
- package/dist/locks-5779ugSW.mjs.map +0 -1
- package/dist/plugin-CjgqBSFy.mjs.map +0 -1
- package/dist/plugin.d.mts.map +0 -1
- package/dist/query-client.d.mts.map +0 -1
- package/dist/query-client.mjs.map +0 -1
- package/dist/registry-CaPbY5lm.mjs.map +0 -1
- package/dist/resolve-context-DklN53BH.mjs.map +0 -1
- package/dist/resolve.d.mts.map +0 -1
- package/dist/resolve.mjs.map +0 -1
- package/dist/routes-siXmjKDA.mjs.map +0 -1
- package/dist/routing-strategy-CAG3tZP0.d.mts.map +0 -1
- package/dist/routing-strategy-D6B4ngT9.mjs.map +0 -1
- package/dist/schema-BYrYzsmO.mjs.map +0 -1
- package/dist/schema-LlK6EkOJ.mjs.map +0 -1
- package/dist/seed.d.mts.map +0 -1
- package/dist/seed.mjs.map +0 -1
- package/dist/site-chrome-BAtQaAqC.mjs.map +0 -1
- package/dist/site-chrome-DNCcePSR.d.mts.map +0 -1
- package/dist/translation-provenance-B41lEDM-.mjs.map +0 -1
- package/dist/translation-provenance-BZ0MlbFR.d.mts.map +0 -1
- package/dist/translation.d.mts.map +0 -1
- package/dist/translation.mjs.map +0 -1
- package/dist/types-BOk9tg_9.d.mts.map +0 -1
- package/dist/versionable-Br_G5VGR.mjs.map +0 -1
- package/dist/versionable-CRvV87nZ.d.mts.map +0 -1
- package/dist/versions-w2ui-Vzb.mjs.map +0 -1
package/dist/seed.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
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 { type DefaultChromeBlock, defaultChromeBlocks } from '@murumets-ee/blocks/library'\nimport { getApp, requireRequestSite, runInEverySite } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity } from '@murumets-ee/entity'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n DETAIL_TEMPLATE_ENTITY_NAME,\n DETAIL_TEMPLATE_TARGET_FIELD,\n} from './detail-template-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 block tree a bootstrapped chrome row starts life with is the library's\n * DEFAULT tree — `defaultChromeBlocks` in `@murumets-ee/blocks/library`, the\n * same function a frontend calls to draw a region the CMS has not published\n * (davatoidud-toolkit PR10). One definition, so a new site's canvas and its\n * visitors cannot be shown two different defaults.\n *\n * Minted with `randomUUID`: block ids are the keys a page's `_overrides` map\n * is written under (`compose-chrome.ts`'s `tagScopedRegion` uses `block.id` as\n * `scopeBlockId`), so two rows sharing one id would make a per-page override\n * of the header also target the footer.\n *\n * What the default holds (the library's Site Header and Site Footer, naming\n * the `main` and `footer` menus, with no copy of ours) and why: see that\n * module's docblock.\n */\nfunction defaultChromeLayout(placement: ChromePlacement): DefaultChromeBlock[] {\n return defaultChromeBlocks(placement, () => randomUUID())\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 // Chrome is per site (multisite D002): every site gets its own header and\n // footer. In a run that names a site (`lumi seed --site`), that site; on a\n // single-site install, the one site; otherwise once per active site — so a\n // site added since the last run gets its chrome on the next `lumi migrate`.\n const perSite = await runInEverySite(() => seedSiteChromeInSite(entity), {\n app,\n label: 'content:site-chrome',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\nasync function seedSiteChromeInSite(entity: Entity): Promise<{ created: number }> {\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 * Create the DRAFT detail template for every entity the app's\n * `detail_template` entity targets (block-library D009), so each has a document\n * an editor can open. Idempotent: an existing row for a target is left alone.\n *\n * The targets are read from the `target` select's own options — the list the\n * app's `detailTemplateEntity({ for })` validated at boot — so the seeder and\n * the entity cannot disagree about which templates exist.\n *\n * A draft, like chrome's: until someone publishes it, every row of the target\n * keeps drawing the plain fallback page, so a migrate changes nothing a visitor\n * sees.\n */\nexport async function seedDetailTemplates(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(DETAIL_TEMPLATE_ENTITY_NAME)\n if (!entity) return { created: 0 }\n const perSite = await runInEverySite(() => seedDetailTemplatesInSite(entity), {\n app,\n label: 'content:detail-templates',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\n/** `yhikas_room_type` → `Yhikas room type`. English; an editor renames it. */\nfunction defaultTemplateLabel(target: string): string {\n const words = target.replace(/[_-]+/g, ' ').trim()\n return `${words.charAt(0).toUpperCase()}${words.slice(1)} detail`\n}\n\nasync function seedDetailTemplatesInSite(entity: Entity): Promise<{ created: number }> {\n const targetField = entity.allFields[DETAIL_TEMPLATE_TARGET_FIELD]\n const targets: readonly string[] = targetField?.type === 'select' ? targetField.options : []\n if (targets.length === 0) return { created: 0 }\n\n const client = createAdminClient(entity)\n const targetColumn = client.getTable()[DETAIL_TEMPLATE_TARGET_FIELD]\n if (!targetColumn) {\n throw new Error(\n `${DETAIL_TEMPLATE_ENTITY_NAME} has no \"${DETAIL_TEMPLATE_TARGET_FIELD}\" column — the ` +\n `entity registered by this app is not the one @murumets-ee/content ships.`,\n )\n }\n\n let created = 0\n for (const target of targets) {\n const existing = await client.findMany({ where: eq(targetColumn, target), limit: 1 })\n if (existing.length > 0) continue\n try {\n await client.create({\n [DETAIL_TEMPLATE_TARGET_FIELD]: target,\n label: defaultTemplateLabel(target),\n // One bound title, not `[]`: an empty canvas offers nothing to click,\n // and the title is the one value every detail declaration can feed.\n layout: [{ id: randomUUID(), type: 'documentTitle', props: {}, traits: {} }],\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is the outcome wanted either way; the\n // unique `target` is what makes that safe to assume.\n if (!isUniqueViolation(err)) throw err\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 // The seeding site's paths only (multisite phase 03) — a reset of one\n // site's pages must not strip another site's URLs.\n const paths = toolkitPathsTable.makeClient(\n getApp().db.readWrite,\n await requireRequestSite('toolkit_paths'),\n )\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":"+fA2KA,SAAS,EAAoB,EAAkD,CAC7E,OAAO,EAAoB,MAAiB,EAAW,CAAC,CAC1D,CAEA,eAAsB,GAA+C,CACnE,IAAM,EAAM,EAAO,EACb,EAAS,EAAI,SAAS,IAAI,CAAkB,EAclD,OAVK,EAUE,CAAE,SAAS,MAJI,MAAqB,EAAqB,CAAM,EAAG,CACvE,MACA,MAAO,qBACT,CAAC,EAAA,CACyB,QAAQ,EAAK,IAAM,EAAM,EAAE,QAAS,CAAC,CAAE,EAV7C,CAAE,QAAS,CAAE,CAWnC,CAEA,eAAe,EAAqB,EAA8C,CAChF,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,CAeA,eAAsB,GAAoD,CACxE,IAAM,EAAM,EAAO,EACb,EAAS,EAAI,SAAS,IAAI,CAA2B,EAM3D,OALK,EAKE,CAAE,SAAS,MAJI,MAAqB,EAA0B,CAAM,EAAG,CAC5E,MACA,MAAO,0BACT,CAAC,EAAA,CACyB,QAAQ,EAAK,IAAM,EAAM,EAAE,QAAS,CAAC,CAAE,EAL7C,CAAE,QAAS,CAAE,CAMnC,CAGA,SAAS,EAAqB,EAAwB,CACpD,IAAM,EAAQ,EAAO,QAAQ,SAAU,GAAG,CAAC,CAAC,KAAK,EACjD,MAAO,GAAG,EAAM,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,EAAM,MAAM,CAAC,EAAE,QAC3D,CAEA,eAAe,EAA0B,EAA8C,CACrF,IAAM,EAAc,EAAO,UAAU,GAC/B,EAA6B,GAAa,OAAS,SAAW,EAAY,QAAU,CAAC,EAC3F,GAAI,EAAQ,SAAW,EAAG,MAAO,CAAE,QAAS,CAAE,EAE9C,IAAM,EAAS,EAAkB,CAAM,EACjC,EAAe,EAAO,SAAS,CAAC,CAAC,GACvC,GAAI,CAAC,EACH,MAAU,MACR,GAAG,EAA4B,WAAW,EAA6B,wFAEzE,EAGF,IAAI,EAAU,EACd,IAAK,IAAM,KAAU,EAEnB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAc,CAAM,EAAG,MAAO,CAAE,CAAC,EAAA,CACvE,OAAS,GACtB,GAAI,CACF,MAAM,EAAO,OAAO,EACjB,GAA+B,EAChC,MAAO,EAAqB,CAAM,EAGlC,OAAQ,CAAC,CAAE,GAAI,EAAW,EAAG,KAAM,gBAAiB,MAAO,CAAC,EAAG,OAAQ,CAAC,CAAE,CAAC,CAC7E,CAAC,EACD,GACF,OAAS,EAAK,CAGZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAEF,MAAO,CAAE,SAAQ,CACnB,CAoBA,eAAsB,EACpB,EACiC,CAGjC,IAAM,EAAQ,EAAkB,WAC9B,EAAO,CAAC,CAAC,GAAG,UACZ,MAAM,EAAmB,eAAe,CAC1C,EACM,EAAkC,CAAC,EACzC,IAAK,IAAM,KAAc,EACvB,EAAQ,IAAe,MAAM,EAAM,WAAW,CAAE,YAAW,CAAC,EAAA,CAAG,OAEjE,OAAO,CACT"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"site-chrome-BAtQaAqC.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior, EntityAdminConfig } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /**\n * How the Page-settings tab and forms NAME this entity's fields. Merged per\n * field over the factory's English defaults, so a project adds only what it\n * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue\n * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:\n * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →\n * humanized name, never the raw field name (editor-curation D003).\n */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\n/**\n * The English defaults every project gets. `label` is the only root field the\n * Page-settings tab offers (`rootFields` below), so it is the one an operator\n * reads; without this it humanized to \"Label\", untranslatable.\n */\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description:\n 'Shown in the admin list, and as the editor title when this document is opened by itself. Visitors never see it.',\n },\n}\n\n/** Per-field shallow merge — a project's key never drops the default English fallback. */\nfunction mergeFieldOverrides(\n project: EntityAdminConfig['fieldOverrides'],\n): NonNullable<EntityAdminConfig['fieldOverrides']> {\n const merged: NonNullable<EntityAdminConfig['fieldOverrides']> = { ...DEFAULT_FIELD_OVERRIDES }\n for (const [name, override] of Object.entries(project ?? {})) {\n merged[name] = { ...merged[name], ...override }\n }\n return merged\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // Each site has its own header and footer (multisite D002/D005): the\n // placement unique is per site, so every site holds one of each.\n site: 'bound',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n //\n // `areaFrom: 'placement'` is what makes this row a HEADER or a FOOTER to\n // everything that asks (davatoidud-toolkit D006): the canvas, op-commit,\n // the entity write path and the agent catalogue all call\n // `resolveDocumentArea(entity, row)`, which reads this declaration. A\n // header block is offered and accepted in the header row and nowhere else.\n layout: field.blockTree({\n translatable: true,\n areaFrom: 'placement',\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options?.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EA0DM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YACE,iHACJ,CACF,EAGA,SAAS,EACP,EACkD,CAClD,IAAM,EAA2D,CAAE,GAAG,CAAwB,EAC9F,IAAK,GAAM,CAAC,EAAM,KAAa,OAAO,QAAQ,GAAW,CAAC,CAAC,EACzD,EAAO,GAAQ,CAAE,GAAG,EAAO,GAAO,GAAG,CAAS,EAEhD,OAAO,CACT,CAYA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,QAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAiBpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,SAAU,YACV,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,GAAS,OAAO,cAAc,EAClE,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"site-chrome-DNCcePSR.d.mts","names":[],"sources":["../src/entities/site-chrome.ts"],"mappings":";;;;;;;;;;;cA0Ga,iBAAA;;KAGD,eAAA,WAA0B,iBAAiB;;cAG1C,uBAAA,EAAyB,QAAA,CAAS,MAAA,CAAO,eAAA;;UAMrC,uBAAA;;;;;;;;;;EAUf,QAAA;;EAEA,UAAA;;EAEA,SAAA;;;;;;EAMA,WAAA;;EAEA,WAAA;;;;;;;;;;;;;;EAcA,SAAA,YAAqB,QAAA,CAAS,MAAA;;;;;;;;;EAS9B,KAAA;IACE,cAAA,GAAiB,iBAAA;EAAA;AAAA;;;;;;;;;;;iBAsCL,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"translation-provenance-B41lEDM-.mjs","names":[],"sources":["../src/schema/translation-provenance.ts","../src/translation-provenance.ts"],"sourcesContent":["/**\n * Translation provenance — what each locale of a document was last saved FROM\n * (agent-access D017).\n *\n * One row per (document, locale), the source locale included:\n *\n * - `contentHash` is a hash of that locale's own reader text as of its last\n * save. For the SOURCE locale this is the value every translation is\n * compared against, and `contentChangedAt` is when it last moved — the date\n * in \"Outdated since …\".\n * - `sourceLocale` / `sourceHash` record, for a translation, which locale it\n * was made from and that locale's `contentHash` at the time. A translation\n * is outdated when the source's `contentHash` no longer equals it.\n * - `method` is how the text got there (`ai` | `human` | `copy`); machine\n * output is \"not reviewed\" until `reviewedAt` is set.\n *\n * Storing the source's hash on its OWN row is what keeps \"outdated\" a\n * comparison of two stored values: a list or a report never has to re-read and\n * re-hash a document to answer it.\n *\n * Shared, not per-entity (like `toolkit_content_locks`). A row is addressed by\n * a document's uuid and is only ever read for a document the caller has\n * already been handed by its own, site-confined client.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const translationProvenanceTable = defineTable({\n name: 'toolkit_translation_provenance',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n entityId: column.varchar({ length: 255, notNull: true, pgName: 'entity_id' }),\n /** A real locale code — the default locale by its code, never `'_'`. */\n locale: column.varchar({ length: 10, notNull: true }),\n contentHash: column.varchar({ length: 64, notNull: true, pgName: 'content_hash' }),\n contentChangedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'content_changed_at',\n }),\n sourceLocale: column.varchar({ length: 10, pgName: 'source_locale' }),\n sourceHash: column.varchar({ length: 64, pgName: 'source_hash' }),\n method: column.varchar({ length: 10 }),\n reviewedAt: column.timestamp({ withTimezone: true, pgName: 'reviewed_at' }),\n // A user id — better-auth ids are random strings, never uuids.\n reviewedBy: column.varchar({ length: 255, pgName: 'reviewed_by' }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n updatedBy: column.varchar({ length: 255, pgName: 'updated_by' }),\n },\n // Named explicitly: drizzle's default\n // (`toolkit_translation_provenance_entity_type_entity_id_locale_unique`) is\n // 66 characters, over Postgres's 63-character identifier limit.\n unique: [\n { on: ['entityType', 'entityId', 'locale'], name: 'uq_translation_provenance_doc_locale' },\n ],\n})\n\nexport const toolkitTranslationProvenance = translationProvenanceTable.table\n","/**\n * Translation provenance — recording what each locale was saved from, and\n * reading \"outdated\" / \"machine translated, not reviewed\" back\n * (agent-access D017).\n *\n * WHAT IS HASHED. A locale's reader TEXT, as the editor would show it: the\n * stored row with that locale's draft laid over it. Text means the entity's\n * translatable `text` / `richtext` fields and the text of every block tree\n * (`treeTextStrings`, the same walk machine translation uses). The strings are\n * hashed as a SORTED list, so moving a block changes nothing; an image, a link\n * or a design field is not text, so swapping one changes nothing either.\n * Slugs are derived, not written, and are left out.\n *\n * WHERE IT IS RECORDED. No single hook sees every locale save (R016), so there\n * are two:\n *\n * - the DRAFT writers record for themselves — this is every edit a person\n * makes to publishable content. `mergeDraft` / `saveDraft` / `deleteDraft`\n * await {@link recordLocaleSave}; the block editor's\n * `DocumentDraftStore.writeDraft` runs inside op-commit's transaction, so it\n * QUEUES the recording ({@link enqueueLocaleSave}) instead of awaiting it;\n * - a write that lands on the live row without a draft (a generic PATCH, a\n * version restore, a seeder, a non-publishable entity) reaches\n * {@link translationProvenanceWriteListener} through\n * `'entity.writeListeners'`.\n *\n * Publish needs no recording of its own: it moves the draft's text onto the\n * row, and the working text — draft over row — is the same before and after.\n *\n * NEVER FAILS A SAVE, AND NEVER HOLDS ONE UP. Recording runs on its own\n * connection, outside the caller's transaction — which also means a save that\n * rolls back can leave its hash recorded until the next save corrects it — and\n * every failure is logged and dropped: a missing\n * provenance row reads as \"unknown\", which is what every document saved before\n * this table existed already is.\n */\n\nimport { AsyncResource } from 'node:async_hooks'\nimport { createHash } from 'node:crypto'\nimport { isProseText, treeTextStrings } from '@murumets-ee/blocks/translation-text'\nimport { getApp, getContext, isTokenDerivedDoor } from '@murumets-ee/core'\nimport { blockTreeFieldsOf, type Entity, type FieldConfig } from '@murumets-ee/entity'\nimport type { EntityWriteObserver } from '@murumets-ee/entity/admin'\nimport { getContentConfig } from './content-config-store.js'\nimport { DEFAULT_LOCALE_SENTINEL } from './locale-utils.js'\nimport { getContentTables } from './schema/registry.js'\nimport { translationProvenanceTable } from './schema/translation-provenance.js'\n\n/** How a locale's text got there. */\nexport type TranslationMethod = 'ai' | 'human' | 'copy'\n\n/** One locale's stored provenance. */\nexport interface TranslationProvenance {\n locale: string\n contentHash: string\n contentChangedAt: Date\n sourceLocale: string | null\n sourceHash: string | null\n method: TranslationMethod | null\n reviewedAt: Date | null\n reviewedBy: string | null\n}\n\ntype Row = Record<string, unknown>\ntype Fields = Readonly<Record<string, FieldConfig>>\n\nfunction fieldsOf(entity: Pick<Entity, 'allFields'>): Fields {\n return entity.allFields as Fields\n}\n\n/**\n * The entity's translatable fields whose value is prose: `text` and `richtext`.\n * A slug is derived from its source field, and a select, a reference or a\n * media id is not text. The translation engine translates exactly these.\n */\nexport function translatableTextFields(entity: Pick<Entity, 'allFields'>): string[] {\n const out: string[] = []\n for (const [name, config] of Object.entries(fieldsOf(entity))) {\n if (config.translatable !== true) continue\n if (config.type === 'text' || config.type === 'richtext') out.push(name)\n }\n return out\n}\n\n/** The entity's translatable block-tree fields. */\nexport function translatableTreeFields(entity: Pick<Entity, 'allFields'>): string[] {\n const fields = fieldsOf(entity)\n return blockTreeFieldsOf(entity as Entity)\n .map((ref) => ref.name)\n .filter((name) => fields[name]?.translatable === true)\n}\n\n/** Whether a document of this entity has any text a translation could go stale against. */\n/**\n * The prose a document's translatable text fields hold right now: `[field,\n * text]` for every field whose value is a sentence — not an address (a\n * translatable `canonicalUrl` is a `text` field too) and not empty.\n */\nexport function translatableFieldTexts(\n entity: Pick<Entity, 'allFields'>,\n data: Readonly<Record<string, unknown>>,\n): [string, string][] {\n const out: [string, string][] = []\n for (const name of translatableTextFields(entity)) {\n const value = data[name]\n if (typeof value === 'string' && isProseText(value)) out.push([name, value])\n }\n return out\n}\n\nexport function hasTranslatableText(entity: Pick<Entity, 'allFields'>): boolean {\n return translatableTextFields(entity).length > 0 || translatableTreeFields(entity).length > 0\n}\n\nconst INLINE_TAG =\n /<\\/?(?:a|abbr|b|code|em|i|mark|s|small|span|strong|sub|sup|u)(?:\\s[^<>]*)?\\/?>/gi\n\n/**\n * The words of an HTML string. An INLINE tag disappears (bold in the middle of\n * a word, or before a comma, is the same words); any other tag is a break\n * between words.\n */\nfunction htmlText(html: string): string {\n return html.replace(INLINE_TAG, '').replace(/<[^<>]*>/g, ' ')\n}\n\nfunction squash(text: string): string {\n return text.replace(/\\s+/g, ' ').trim()\n}\n\n/**\n * Hash the reader text of one locale's data (a row, or a row with a draft laid\n * over it). Order-independent across blocks; whitespace-insensitive.\n */\nexport function documentTextHash(entity: Pick<Entity, 'allFields'>, data: Row): string {\n const parts: string[] = []\n const fields = fieldsOf(entity)\n for (const name of translatableTextFields(entity)) {\n const value = data[name]\n if (typeof value !== 'string' || !isProseText(value)) continue\n // An entity `richtext` column is an HTML string: its tags are not text, so\n // changing a link's target or making a word bold is not a text change.\n const text = fields[name]?.type === 'richtext' ? htmlText(value) : value\n parts.push(`${name}=${squash(text)}`)\n }\n for (const name of translatableTreeFields(entity)) {\n const value = data[name]\n if (!Array.isArray(value)) continue\n for (const text of treeTextStrings(value as never)) {\n const squashed = squash(text)\n if (squashed !== '') parts.push(squashed)\n }\n }\n parts.sort()\n return createHash('sha256').update(JSON.stringify(parts)).digest('hex')\n}\n\n/** `'_'` → the configured default locale's code; anything else unchanged. */\nfunction realLocale(locale: string, defaultLocale: string): string {\n return locale === DEFAULT_LOCALE_SENTINEL ? defaultLocale : locale\n}\n\ntype Db = Parameters<typeof translationProvenanceTable.makeClient>[0]\n\nfunction provenanceClient(db: Db = getApp().db.readWrite) {\n return translationProvenanceTable.makeClient(db)\n}\n\n/** The app, or `undefined` in a realm that has none (a unit test of a neighbouring module). */\nfunction appOrNone(): ReturnType<typeof getApp> | undefined {\n try {\n return getApp()\n } catch {\n // No app in this realm — there is no database to record against either.\n return undefined\n }\n}\n\nfunction toProvenance(row: Row): TranslationProvenance {\n const method = row.method\n return {\n locale: String(row.locale),\n contentHash: String(row.contentHash),\n contentChangedAt: row.contentChangedAt as Date,\n sourceLocale: typeof row.sourceLocale === 'string' ? row.sourceLocale : null,\n sourceHash: typeof row.sourceHash === 'string' ? row.sourceHash : null,\n method: method === 'ai' || method === 'human' || method === 'copy' ? method : null,\n reviewedAt: (row.reviewedAt as Date | null | undefined) ?? null,\n reviewedBy: typeof row.reviewedBy === 'string' ? row.reviewedBy : null,\n }\n}\n\n/**\n * One locale's stored row (locale-merged) and its working data — the row with\n * that locale's draft, if any, laid over it. `null` when the caller cannot read\n * the row.\n */\nasync function readWorkingData(\n entity: Entity,\n entityId: string,\n locale: string,\n defaultLocale: string,\n draftData?: Row,\n): Promise<{ row: Row; working: Row; drafted: boolean } | null> {\n const app = getApp()\n const isDefault = locale === defaultLocale\n const row = (await app.getClient(entity).findById(entityId, {\n ...(isDefault ? {} : { locale, defaultLocale }),\n })) as Row | null\n if (!row) return null\n if (draftData !== undefined) return { row, working: { ...row, ...draftData }, drafted: true }\n const { drafts } = getContentTables(entity.name)\n if (!drafts) return { row, working: row, drafted: false }\n const { siteForDocumentType } = await import('./internal/document-site.js')\n const draft = await drafts\n .makeClient(app.db.readWrite, await siteForDocumentType(entity.name))\n .findOne({ entityId, locale: isDefault ? DEFAULT_LOCALE_SENTINEL : locale })\n const data = draft?.data\n return data && typeof data === 'object'\n ? { row, working: { ...row, ...(data as Row) }, drafted: true }\n : { row, working: row, drafted: false }\n}\n\n/** What {@link recordLocaleSave} is told about the save. */\nexport interface LocaleSaveInput {\n /** The entity type. One the app does not know is ignored. */\n entityName: string\n entityId: string\n /** The saved locale — a real code, or `'_'` for the default locale. */\n locale: string\n /** Who saved. */\n userId?: string | undefined\n /**\n * The locale's whole draft AS JUST WRITTEN, when the caller holds it. A draft\n * writer inside a transaction must pass it: this module reads on its own\n * connection and would otherwise see the draft as it was before the save.\n */\n draftData?: Readonly<Record<string, unknown>> | undefined\n /**\n * `'ai'` when the caller KNOWS the text is machine output (the translation\n * engine). Left out, a save through a token-derived door is `'ai'` too — an\n * agent typing a translation is a machine translating — and a person's save\n * is `'human'`, or `'copy'` when the text is identical to the source's.\n */\n method?: 'ai' | undefined\n /**\n * This save REMOVED text rather than wrote it (a draft was discarded). It is\n * recorded — the working text changed back to the row's — but it is never\n * machine output, whoever did it: nobody wrote a word.\n */\n reverted?: boolean | undefined\n /**\n * Refresh this locale's TEXT HASH only — never its method, its source or its\n * review. For a locale that was READ as a source, not written: without it a\n * non-default source would be re-judged as a translation, under the caller's\n * door, and a hand-written Russian page an agent translated FROM would be\n * recorded as machine output.\n */\n contentOnly?: boolean | undefined\n /** The locale the text was translated from. Defaults to the default locale. */\n sourceLocale?: string | undefined\n /**\n * The source's text hash AS TRANSLATED, when the caller translated something\n * other than the source's current working text (its published row).\n */\n sourceHash?: string | undefined\n}\n\n/**\n * Record one locale save. Reads that locale's working text, stores its hash,\n * and — for a translation — the source hash it now corresponds to.\n *\n * Machine output stays `ai` and unreviewed through later human edits: fixing a\n * typo is not a review. Only \"Mark reviewed\" ({@link markTranslationReviewed})\n * clears it, and a fresh machine translation sets it again.\n */\nexport async function recordLocaleSave(input: LocaleSaveInput): Promise<void> {\n const { entityName, entityId } = input\n const app = appOrNone()\n if (!app) return\n try {\n const entity = app.entities.get(entityName)\n if (!entity || !hasTranslatableText(entity)) return\n const defaultLocale = getContentConfig().defaultLocale\n const locale = realLocale(input.locale, defaultLocale)\n const read = await readWorkingData(\n entity,\n entityId,\n locale,\n defaultLocale,\n input.draftData === undefined ? undefined : { ...input.draftData },\n )\n if (!read) return\n const contentHash = documentTextHash(entity, read.working)\n const client = provenanceClient()\n const now = new Date()\n const key = { entityType: entity.name, entityId }\n\n if (\n input.contentOnly === true ||\n (locale === defaultLocale && input.sourceLocale === undefined)\n ) {\n // A hash-only refresh and a discard author nothing — no user is recorded.\n const author =\n input.contentOnly === true || input.reverted === true ? undefined : input.userId\n await upsertContent(client, { ...key, locale }, contentHash, now, author)\n return\n }\n\n const sourceLocale = realLocale(input.sourceLocale ?? defaultLocale, defaultLocale)\n if (sourceLocale === locale) return\n let sourceHash = input.sourceHash\n if (sourceHash === undefined) {\n // The source's own row is the comparison target, so make sure it exists:\n // a document last saved before this table did has none.\n const source = await client.findOne({ ...key, locale: sourceLocale })\n if (source) {\n sourceHash = String(source.contentHash)\n } else {\n const sourceData = await readWorkingData(entity, entityId, sourceLocale, defaultLocale)\n if (!sourceData) return\n sourceHash = documentTextHash(entity, sourceData.working)\n await upsertContent(client, { ...key, locale: sourceLocale }, sourceHash, now, undefined)\n }\n }\n\n const existing = await client.findOne({ ...key, locale })\n // Did this save change the locale's TEXT? Against the recorded hash when\n // there is one. A locale with nothing recorded (every document saved before\n // this table existed, and every brand-new translation) has no hash to\n // compare with, so:\n // - with a DRAFT over the row, the row is the \"before\": a draft whose text\n // equals the row's changed no words — an agent swapping an image in a\n // hand-translated page has not machine-translated it;\n // - with NO draft, the write landed on the row itself and there is no\n // \"before\" left to read. Then the question is whether the locale holds a\n // translation at all (its text differs from the source's). That errs\n // toward `ai` for an agent's first write to an old hand translation —\n // a badge a person clears with \"Mark reviewed\" — and never toward\n // recording machine text as human, which is the failure that matters.\n const changed =\n existing !== null\n ? existing.contentHash !== contentHash\n : read.drafted\n ? documentTextHash(entity, read.row) !== contentHash\n : contentHash !== sourceHash\n // Machine output is: what the engine says it wrote, or TEXT that arrived\n // through a token-derived door. The second needs the text to have changed —\n // an agent publishing, or re-saving the same words, has written nothing,\n // and must not turn a reviewed translation back into an unreviewed one. A\n // discard writes nothing either.\n const machine =\n input.method === 'ai' ||\n (changed && input.reverted !== true && isTokenDerivedDoor(getContext()?.door))\n const authored =\n input.userId !== undefined &&\n input.reverted !== true &&\n (existing === null || existing.contentHash !== contentHash)\n const wasMachine = existing?.method === 'ai'\n const method: TranslationMethod =\n machine || wasMachine ? 'ai' : contentHash === sourceHash ? 'copy' : 'human'\n await client.upsert(\n {\n ...key,\n locale,\n contentHash,\n contentChangedAt: now,\n sourceLocale,\n sourceHash,\n method,\n reviewedAt: null,\n reviewedBy: null,\n updatedAt: now,\n updatedBy: input.userId ?? null,\n },\n {\n target: ['entityType', 'entityId', 'locale'],\n set: {\n contentHash,\n ...((existing === null || existing.contentHash !== contentHash) && {\n contentChangedAt: now,\n }),\n sourceLocale,\n sourceHash,\n method,\n // Fresh machine output is unreviewed again; anything else keeps the\n // review it had.\n ...(machine && { reviewedAt: null, reviewedBy: null }),\n updatedAt: now,\n // WHO LAST WROTE THE TEXT — what \"an agent may not review its own\n // translation\" is checked against. So it moves only when this save\n // changed the words AND says who did: a discard, a publish or a\n // recording with no known user wrote nothing, and letting one of\n // them blank it would let an agent launder its own authorship\n // (write, stage a draft, discard it, then review).\n ...(authored && { updatedBy: input.userId }),\n },\n },\n )\n } catch (error) {\n // Deliberately swallowed — see the module docblock (\"never fails a save\").\n app.logger.warn(\n { err: error, entity: entityName, entityId, locale: input.locale },\n 'translation provenance: could not record a locale save',\n )\n }\n}\n\nasync function upsertContent(\n client: ReturnType<typeof provenanceClient>,\n key: { entityType: string; entityId: string; locale: string },\n contentHash: string,\n now: Date,\n userId: string | undefined,\n): Promise<void> {\n const existing = await client.findOne(key)\n if (existing && existing.contentHash === contentHash) return\n await client.upsert(\n {\n ...key,\n contentHash,\n contentChangedAt: now,\n updatedAt: now,\n updatedBy: userId ?? null,\n },\n {\n target: ['entityType', 'entityId', 'locale'],\n // The author moves only when one is known — see `authored` above.\n set: {\n contentHash,\n contentChangedAt: now,\n updatedAt: now,\n ...(userId !== undefined && { updatedBy: userId }),\n },\n },\n )\n}\n\n/**\n * Mark a translation reviewed. The caller checks permission.\n *\n * - `'none'` — nothing is recorded for that locale; there is nothing to review.\n * - `'self'` — `refuseSelf` was set and the reviewer is the one who last wrote\n * the text. A review is someone ELSE vouching for it: an agent that could\n * clear \"not reviewed\" on its own output would make the badge say nothing\n * (the approval queue refuses the asking principal for the same reason).\n */\nexport async function markTranslationReviewed(input: {\n entityType: string\n entityId: string\n locale: string\n userId: string\n refuseSelf?: boolean | undefined\n}): Promise<'reviewed' | 'none' | 'self'> {\n const client = provenanceClient()\n const key = { entityType: input.entityType, entityId: input.entityId, locale: input.locale }\n const existing = await client.findOne(key)\n if (!existing) return 'none'\n if (input.refuseSelf === true && existing.updatedBy === input.userId) return 'self'\n const now = new Date()\n // `updatedBy` is left alone: it is who last WROTE the text.\n await client.update(key, { reviewedAt: now, reviewedBy: input.userId, updatedAt: now })\n return 'reviewed'\n}\n\n/** Every stored provenance row for a set of documents of one entity type — ONE query. */\nexport async function getTranslationProvenanceBatch(\n entityType: string,\n entityIds: readonly string[],\n db?: Db,\n): Promise<Map<string, Map<string, TranslationProvenance>>> {\n const out = new Map<string, Map<string, TranslationProvenance>>()\n if (entityIds.length === 0) return out\n const rows = await provenanceClient(db).findMany({\n where: { entityType: { eq: entityType }, entityId: { in: [...entityIds] } },\n })\n for (const row of rows) {\n const id = String(row.entityId)\n let byLocale = out.get(id)\n if (!byLocale) {\n byLocale = new Map()\n out.set(id, byLocale)\n }\n const provenance = toProvenance(row as Row)\n byLocale.set(provenance.locale, provenance)\n }\n return out\n}\n\n/** What a translation's provenance says about it, against its source's. */\nexport interface TranslationFreshness {\n method: TranslationMethod | null\n sourceLocale: string | null\n /** The source text has changed since this translation was saved. */\n outdated: boolean\n /** When the source text changed — ISO, only when {@link outdated}. */\n outdatedSince: string | null\n /** Machine output nobody has marked reviewed. */\n machineUnreviewed: boolean\n reviewedAt: string | null\n}\n\n/** Compare one locale's provenance with its source's. `null` when nothing is recorded. */\nexport function translationFreshness(\n byLocale: ReadonlyMap<string, TranslationProvenance> | undefined,\n locale: string,\n): TranslationFreshness | null {\n const own = byLocale?.get(locale)\n if (!own) return null\n const source = own.sourceLocale !== null ? byLocale?.get(own.sourceLocale) : undefined\n const outdated =\n source !== undefined && own.sourceHash !== null && source.contentHash !== own.sourceHash\n return {\n method: own.method,\n sourceLocale: own.sourceLocale,\n outdated,\n outdatedSince: outdated && source ? new Date(source.contentChangedAt).toISOString() : null,\n machineUnreviewed: own.method === 'ai' && own.reviewedAt === null,\n reviewedAt: own.reviewedAt ? new Date(own.reviewedAt).toISOString() : null,\n }\n}\n\n// ---------------------------------------------------------------------------\n// The queue — recordings that must not be awaited by the write that caused them\n// ---------------------------------------------------------------------------\n\n/**\n * A recording waiting to run. `run` is bound to the async context it was\n * QUEUED in (`AsyncResource.bind`): the queue is drained from the completion of\n * whichever recording finished last, and without the binding a queued recording\n * would run under THAT request's context — another caller's door, user and\n * site (review, PR10).\n */\ninterface Pending {\n run: () => Promise<void>\n}\n\n/** Keyed so a burst of writes to one document and locale collapses to the last. */\nconst pending = new Map<string, Pending>()\n/** Recordings running now, by key — one document and locale is never recorded twice at once. */\nconst inFlight = new Map<string, Promise<void>>()\n\n/** Recordings in flight at once — far below any connection pool. */\nconst MAX_CONCURRENT = 2\n/**\n * Beyond this many waiting, new ones are dropped (and logged): a bulk import\n * must not queue without end, and each entry may hold a draft's data.\n */\nconst MAX_PENDING = 200\n\nfunction drain(): void {\n for (const [key, entry] of pending) {\n if (inFlight.size >= MAX_CONCURRENT) return\n // The same document and locale is already being recorded: this one waits\n // for it. Started together, the OLDER one could finish last and leave its\n // hash behind — two quick edits of one page are ordinary.\n if (inFlight.has(key)) continue\n pending.delete(key)\n const done = entry.run().finally(() => {\n inFlight.delete(key)\n drain()\n })\n inFlight.set(key, done)\n }\n}\n\n/**\n * Record a locale save WITHOUT being awaited by it: bounded, coalesced per\n * document and locale, and run under the caller's own request context.\n *\n * For a writer that holds a transaction (the block editor's draft write inside\n * op-commit's): awaiting a recording there would take a SECOND pool connection\n * while holding the first, and enough concurrent commits would each wait for a\n * connection none of them can release.\n */\nexport function enqueueLocaleSave(input: LocaleSaveInput): void {\n const app = appOrNone()\n if (!app) return\n const key = queueKey(input.entityName, input.entityId, input.locale)\n if (!pending.has(key) && pending.size >= MAX_PENDING) {\n app.logger.warn(\n {\n entity: input.entityName,\n entityId: input.entityId,\n locale: input.locale,\n waiting: pending.size,\n },\n 'translation provenance: too many recordings waiting — this save was not recorded',\n )\n return\n }\n pending.set(key, { run: AsyncResource.bind(() => recordLocaleSave(input)) })\n drain()\n}\n\n/**\n * A write that landed on the live row — the `'entity.writeListeners'`\n * contribution. Synchronous and `void`, per the slot's contract.\n *\n * A `delete` is NOT recorded and removes nothing: the event does not say\n * whether the row was trashed or purged, and a trashed document that is\n * restored must come back with what it was translated from and whether that\n * was reviewed. A purged document's rows are a few hundred bytes addressed by\n * a uuid that is never reused.\n */\nexport const translationProvenanceWriteListener: EntityWriteObserver = (event) => {\n if (event.action === 'delete') return\n const app = appOrNone()\n if (!app) return\n const entity = app.entities.get(event.entityName)\n if (!entity || !hasTranslatableText(entity)) return\n enqueueLocaleSave({\n entityName: event.entityName,\n entityId: event.id,\n locale: event.locale ?? DEFAULT_LOCALE_SENTINEL,\n userId: getContext()?.user?.id,\n })\n}\n\nfunction queueKey(entityName: string, entityId: string, locale: string): string {\n return `${entityName}:${entityId}:${locale}`\n}\n\n/**\n * Resolves once the queued recordings for ONE document and locale have run —\n * for a caller about to write that row itself, which must come last. Bounded:\n * it waits for that key only, never the whole queue, and gives up after\n * `maxMs` rather than hold a request on a recording that will not settle.\n */\nexport async function localeSaveSettled(\n entityName: string,\n entityId: string,\n locale: string,\n maxMs = 3000,\n): Promise<void> {\n const key = queueKey(entityName, entityId, locale)\n const deadline = Date.now() + maxMs\n while (Date.now() < deadline) {\n const running = inFlight.get(key)\n if (running !== undefined) {\n await Promise.race([running, new Promise((resolve) => setTimeout(resolve, 50))])\n continue\n }\n if (!pending.has(key)) return\n await new Promise((resolve) => setTimeout(resolve, 5))\n }\n}\n\n/** Resolves once every queued recording has run. For tests; a request uses {@link localeSaveSettled}. */\nexport async function provenanceQueueIdle(): Promise<void> {\n while (inFlight.size > 0 || pending.size > 0)\n await new Promise((resolve) => setTimeout(resolve, 5))\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"translation-provenance-BZ0MlbFR.d.mts","names":[],"sources":["../src/schema/translation-provenance.ts","../src/translation-provenance.ts"],"mappings":";;;;;;AA2BA;;;;;;;;;;;;;;;;;;;;cAAa,0BAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KCsBD,iBAAA;;UAGK,qBAAA;EACf,MAAA;EACA,WAAA;EACA,gBAAA,EAAkB,IAAA;EAClB,YAAA;EACA,UAAA;EACA,MAAA,EAAQ,iBAAA;EACR,UAAA,EAAY,IAAA;EACZ,UAAA;AAAA;AAAA,KAGG,GAAA,GAAM,MAAM;;;;;;iBAYD,sBAAA,CAAuB,MAAA,EAAQ,IAAI,CAAC,MAAA;;iBAUpC,sBAAA,CAAuB,MAAA,EAAQ,IAAI,CAAC,MAAA;;;;;;;iBAapC,sBAAA,CACd,MAAA,EAAQ,IAAA,CAAK,MAAA,gBACb,IAAA,EAAM,QAAA,CAAS,MAAA;AAAA,iBAUD,mBAAA,CAAoB,MAAA,EAAQ,IAAI,CAAC,MAAA;;;;;iBAwBjC,gBAAA,CAAiB,MAAA,EAAQ,IAAA,CAAK,MAAA,gBAAsB,IAAA,EAAM,GAAA;AAAA,KA4BrE,EAAA,GAAK,UAAU,QAAQ,0BAAA,CAA2B,UAAA;;UA8DtC,eAAA;;EAEf,UAAA;EACA,QAAA;;EAEA,MAAA;;EAEA,MAAA;;;;;;EAMA,SAAA,GAAY,QAAQ,CAAC,MAAA;;;;;;;EAOrB,MAAA;;;;;;EAMA,QAAA;;;;;;;;EAQA,WAAA;;EAEA,YAAA;;;;;EAKA,UAAA;AAAA;;;;;;;;;iBAWoB,gBAAA,CAAiB,KAAA,EAAO,eAAA,GAAkB,OAAO;;;;;;;;;;iBA2KjD,uBAAA,CAAwB,KAAA;EAC5C,UAAA;EACA,QAAA;EACA,MAAA;EACA,MAAA;EACA,UAAA;AAAA,IACE,OAAO;AApZkB;AAAA,iBAiaP,6BAAA,CACpB,UAAA,UACA,SAAA,qBACA,EAAA,GAAK,EAAA,GACJ,OAAA,CAAQ,GAAA,SAAY,GAAA,SAAY,qBAAA;;UAoBlB,oBAAA;EACf,MAAA,EAAQ,iBAAiB;EACzB,YAAA;EAjbY;EAmbZ,QAAA;EAnbgB;EAqbhB,aAAA;EA1bA;EA4bA,iBAAA;EACA,UAAA;AAAA;;iBAIc,oBAAA,CACd,QAAA,EAAU,WAAA,SAAoB,qBAAA,eAC9B,MAAA,WACC,oBAAA;;;;;;AA9bS;AACX;;;iBAkgBe,iBAAA,CAAkB,KAAsB,EAAf,eAAe;;;;;;;iBAsDlC,iBAAA,CACpB,UAAA,UACA,QAAA,UACA,MAAA,UACA,KAAA,YACC,OAAO;AAriB8D;AAAA,iBAojBlD,mBAAA,CAAA,GAAuB,OAAO"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"translation.d.mts","names":[],"sources":["../src/translatable-sources.ts"],"mappings":";;;;;;AAkBA;;;;;;;;AAIW;AAIX;;;UARiB,sBAAA;EAUV;EARL,GAAA;EACA,IAAA;EACA,SAAA;AAAA;;UAIe,sBAAA;EACf,EAAA;EACA,KAAK;AAAA;AAAA,UAGU,sBAAA;EAuBkB;EArBjC,QAAA;EA8CwD;EA5CxD,KAAA,EAAO,sBAAsB;AAAA;;;;;;;;;;;;;;;;;UAmBd,kBAAA;EAyBiC;EAvBhD,IAAA;EA6BA;EA3BA,KAAA;EA6BE;EA3BF,QAAA;EA6BE;EA3BF,UAAA;IAAc,QAAA;IAAkB,MAAA;EAAA;EA8B9B;EA5BF,KAAA;EA8BE;;;;;;;;;;AAIS;AACZ;;;EApBC,IAAA,CAAK,KAAA;IAAS,KAAA;IAAgB,KAAA;EAAA,IAAkB,OAAA,CAAQ,sBAAA;EAmCtD;;;;AAA+C;EA7BjD,IAAA,CAAK,KAAA;IACH,EAAA;IACA,YAAA;IACA,YAAA;IACA,IAAA;EAAA,IACE,OAAA,CAAQ,sBAAA;EACZ,KAAA,CAAM,KAAA;IACJ,EAAA;IACA,YAAA;IACA,QAAA,UA4CF;IA1CE,KAAA,EAAO,QAAA,CAAS,MAAA;IAChB,IAAA;MAAQ,EAAA;MAAY,IAAA;IAAA;EAAA,IAClB,OAAA;AAAA;AAAA;EAAA,UAIM,uBAAA;;;;;;IAMR,6BAAA,WAAwC,kBAAA;;;;;;IAMxC,yBAAA,EAA2B,oBAAoB;EAAA;AAAA;;;;;;;;;iBAYnC,sBAAA,CAAA,GAA0B,kBAAkB;;UAS3C,oBAAA;;EAEf,IAAA;EACA,UAAA;IAAc,QAAA;IAAkB,MAAA;EAAA;AAAA;;iBAIlB,uBAAA,CAAA,GAA2B,oBAAoB"}
|
package/dist/translation.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"translation.mjs","names":[],"sources":["../src/translatable-sources.ts"],"sourcesContent":["/**\n * `'content.translatableSources'` — text OUTSIDE the entity translation tables\n * that a whole-site translation still has to cover (agent-access D019).\n *\n * A menu keeps its per-locale labels inside one JSON tree, so the translation\n * engine's walk over translatable entity fields never sees them. Content owns\n * this slot so that the engine (`@murumets-ee/translation`) and the plugin that\n * owns such text (`@murumets-ee/menus`) need no dependency on each other: the\n * owner DECLARES a {@link TranslatableSource} on its plugin's `contributes`,\n * and the engine reads them with {@link getTranslatableSources}.\n *\n * No `server-only` and no database import: this module is types plus one\n * registry read, so a plugin entry that jiti loads may import from it.\n */\n\nimport { getContributions } from '@murumets-ee/core'\n\n/** One piece of source-locale text a {@link TranslatableSource} wants translated. */\nexport interface TranslatableSourceText {\n /** Stable within one item — the job hands it back to `write`. */\n key: string\n text: string\n maxLength?: number\n}\n\n/** One translatable thing of a kind — a menu, say — as `list` reports it. */\nexport interface TranslatableSourceItem {\n id: string\n label: string\n}\n\nexport interface TranslatableSourceRead {\n /** What `texts` was read from. `write` refuses (`'changed'`) when the item no longer matches it. */\n revision: string\n /** The source-locale texts that need a translation for this target, per `mode`. Empty = nothing to do. */\n texts: TranslatableSourceText[]\n}\n\n/**\n * Text outside the entity translation tables that a whole-site translation\n * covers (agent-access D019).\n *\n * ⚠️ **Every method runs INSIDE the caller's request context.** `getApp()` /\n * `app.getClient(entity)` see the ambient user and permission checker, and that\n * is the only authority a source has: it does its reads and writes through\n * `AdminClient` / `ContentClient` and NEVER elevates — no\n * `createSystemAdminClient`, no `runAsCli`, no hand-built context. The engine\n * checks {@link TranslatableSource.permission} with the starter's own checker\n * before it lists anything; the entity firewall under these methods is the\n * second gate, not a formality.\n *\n * Each call touches ONE item. `list` is the only enumeration, and it is a\n * bounded page.\n */\nexport interface TranslatableSource {\n /** Unique: `'menu'`. Lower-case `[a-z][a-z0-9_]*`. */\n kind: string\n /** English fallback beside {@link labelKey} (CLAUDE.md \"Interface copy\") — never rendered when the key resolves. */\n label: string\n /** next-intl key for {@link label}, in the contributing plugin's own namespace. */\n labelKey: string\n /** What the caller must hold to translate this kind. The job checks it with the starter's own checker before listing. */\n permission: { resource: string; action: string }\n /** `true`: `write` lands in a draft and nothing is live until someone publishes. */\n draft: boolean\n /**\n * A bounded keyset page, ordered by id: the items after `after`, at most\n * `limit` of them. `limit` is never above 200, and a source must honour it\n * EXACTLY up to that: the job asks for one item more than it reads, and\n * takes a page of FEWER rows than it asked for to mean \"there are no more\n * rows\". A source that caps its page lower than the `limit` it was given\n * silently ends the scan there — the rest is never planned, and nothing\n * says so.\n *\n * Under scope `all` a source's items are taken by EVERY run: a source\n * records nothing that says \"this is already a current machine\n * translation\" (`read` with mode `'all'` returns every text that has a\n * source). Only scope `missing` comes to an end for a source.\n */\n list(input: { after?: string; limit: number }): Promise<TranslatableSourceItem[]>\n /**\n * `null` = no such item (or not readable by this caller). mode `'missing'`:\n * only texts the target does not have yet; `'all'`: every text that has a\n * source.\n */\n read(input: {\n id: string\n sourceLocale: string\n targetLocale: string\n mode: 'missing' | 'all'\n }): Promise<TranslatableSourceRead | null>\n write(input: {\n id: string\n targetLocale: string\n revision: string\n /** key → translated text, for keys `read` returned. A key not in `texts` is left alone. */\n texts: Readonly<Record<string, string>>\n user: { id: string; name?: string }\n }): Promise<'written' | 'changed' | 'not_found'>\n}\n\ndeclare module '@murumets-ee/core' {\n interface PluginContributionSlots {\n /**\n * Translatable text that lives outside the entity translation tables, one\n * list per contributing plugin. Read it with `getTranslatableSources()`\n * from `@murumets-ee/content/translation`, which flattens the lists.\n */\n 'content.translatableSources': readonly TranslatableSource[]\n /**\n * Where a person starts a whole-site translation run — the admin page, and\n * the permission it needs. Contributed by `@murumets-ee/translation`; read\n * with `getSiteTranslationEntry()` by the editor, which links to it.\n */\n 'content.siteTranslation': SiteTranslationEntry\n }\n}\n\n/**\n * Every contributed {@link TranslatableSource}, as one flat list.\n *\n * One entry per `kind`: when two plugins contribute the same kind the LATER\n * plugin (in `plugins:` order) wins and keeps the earlier one's position, so\n * an app can replace a source without the list reordering. `[]` when nothing\n * contributes — an app without menus simply has no extra text to translate.\n */\nexport function getTranslatableSources(): TranslatableSource[] {\n const byKind = new Map<string, TranslatableSource>()\n for (const contribution of getContributions('content.translatableSources')) {\n for (const source of contribution) byKind.set(source.kind, source)\n }\n return [...byKind.values()]\n}\n\n/** The whole-site translation page, as the content editor links to it. */\nexport interface SiteTranslationEntry {\n /** An admin path without the locale prefix, e.g. `/admin/translate-site`. */\n href: string\n permission: { resource: string; action: string }\n}\n\n/** The contributed whole-site translation page, or `undefined` when no plugin offers one. */\nexport function getSiteTranslationEntry(): SiteTranslationEntry | undefined {\n return getContributions('content.siteTranslation').at(-1)\n}\n"],"mappings":"uLA8HA,SAAgB,GAA+C,CAC7D,IAAM,EAAS,IAAI,IACnB,IAAK,IAAM,KAAgB,EAAiB,6BAA6B,EACvE,IAAK,IAAM,KAAU,EAAc,EAAO,IAAI,EAAO,KAAM,CAAM,EAEnE,MAAO,CAAC,GAAG,EAAO,OAAO,CAAC,CAC5B,CAUA,SAAgB,GAA4D,CAC1E,OAAO,EAAiB,yBAAyB,CAAC,CAAC,GAAG,EAAE,CAC1D"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"types-BOk9tg_9.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;;UAUiB,YAAA;EACf,IAAA;EACA,KAAK;AAAA;AAIP;AAAA,KAAY,eAAA;;;AAAe;AAQ3B;;;KAAY,eAAA;AAAe;AAU3B;;;;;;;AAV2B,UAUV,WAAA;EACf,IAAA;EACA,KAAA;EACA,OAAA;EACA,SAAA,EAAW,eAAe;EAC1B,SAAA;AAAA;;;;;;;UASe,UAAA;EACf,aAAA;EACA,eAAA,EAAiB,eAAe;AAAA;AAAA,UAGjB,aAAA;EACf,OAAA,EAAS,YAAY;EACrB,aAAA;EAAA;;;;;AAmC+B;AAOjC;;;;AAIU;AAOV;;;;;;;;;EAhCE,oBAAA;EA0Ce;;;;;;;;;;;;;EA5Bf,SAAA;IAAwB,OAAA;EAAA;AAAA;AAAA,UAOT,kBAAA;EAsBf;EApBA,SAAA;EAoBU;EAlBV,QAAQ;AAAA;AAAA,UAOO,YAAA,eAA2B,MAAA,oBAA0B,MAAA;EACpE,EAAA;EACA,QAAA;EACA,OAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,KAAA,EAAO,OAAA,CAAQ,KAAA;EACf,MAAA;EACA,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,UAAA;AAAA;AAAA,UAae,sBAAA;EACf,QAAA;EACA,KAAA;EACA,UAAA;EACA,OAAA;EAmBA;;;AAEiB;AAOnB;EAtBE,SAAA,GAAY,oBAAoB;AAAA;AAAA,UAGjB,iBAAA;EAAA,CACd,MAAA,WAAiB,sBAAsB;AAAA;AAAA,UAGzB,iBAAA;EACf,KAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;EAqByB;EAnBzB,QAAA;EAmBwC;EAjBxC,iBAAA;AAAA;AAAA,UAOe,mBAAA;EACf,MAAA;EACA,MAAA;EACA,WAAA,EAAa,IAAI;AAAA;AAAA,UAOF,UAAA,eAAyB,MAAA,oBAA0B,MAAA;EAClE,EAAA;EACA,QAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,SAAA,EAAW,IAAA;AAAA;AAAA,UAOI,QAAA;EACf,QAAA;EACA,YAAA;EACA,QAAA,EAAU,IAAA;EACV,SAAA,EAAW,IAAI;AAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"versionable-Br_G5VGR.mjs","names":[],"sources":["../src/behaviors/versionable.ts"],"sourcesContent":["/**\n * Versionable behavior — full version history with snapshots.\n *\n * Extends the concept of revisionable() (which only tracks a counter)\n * with actual version snapshots stored in {entity}_versions table.\n *\n * The behavior itself adds fields + hooks for version tracking.\n * Actual snapshot persistence happens in ContentClient (which has DB access).\n *\n * Options:\n * - maxPerDoc: Maximum versions kept per document (default: 50)\n * - autosave: Enable smart autosave (default: false)\n */\n\nimport type { Behavior, DateField, NumberField } from '@murumets-ee/entity'\n\nimport { field } from '@murumets-ee/entity'\nimport type { VersionableOptions } from '../types.js'\n\nexport type VersionableFields = {\n _version: NumberField & { required: true }\n _versionedAt: DateField\n}\n\nconst DEFAULT_OPTIONS: Required<VersionableOptions> = {\n maxPerDoc: 50,\n autosave: false,\n}\n\nexport function versionable(options?: VersionableOptions): Behavior<VersionableFields> {\n const opts = { ...DEFAULT_OPTIONS, ...options }\n\n // Clamp maxPerDoc to a positive integer. A zero or negative value would\n // propagate to `pruneVersions` where `client.deleteMany({ id: { notIn: [] } })`\n // could match every row — i.e. delete the entire version history for\n // that entity+locale. Surface the misconfiguration here, not at first prune.\n if (!Number.isInteger(opts.maxPerDoc) || opts.maxPerDoc < 1) {\n throw new Error(\n `versionable({ maxPerDoc: ${opts.maxPerDoc} }) — maxPerDoc must be a positive integer.`,\n )\n }\n\n // Attach options to the behavior for ContentClient to read\n const behavior: Behavior<VersionableFields> & {\n _versionableOptions: Required<VersionableOptions>\n } = {\n name: 'versionable',\n fields: {\n // Both fields are managed by the versionable hooks + ContentClient's\n // snapshot persistence. The HTTP PATCH surface must not be able to\n // poison either — `_version` is also in `validation.ts`'s legacy omit\n // list (so this is belt-and-braces for `_version`), but `_versionedAt`\n // was previously writable via PATCH and only stripped from the read\n // DTO. `internal: true` closes that gap and makes the boundary\n // consistent with workflowable.\n _version: field.number({ required: true, default: 1, integer: true, internal: true }),\n _versionedAt: field.date({ internal: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._version = 1\n data._versionedAt = new Date()\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Only update _versionedAt here. The _version increment is handled by\n // createVersionSnapshot which reads the current version from the DB.\n // The beforeUpdate hook only receives partial data (the PATCH payload),\n // so it can't reliably increment _version without the current value.\n data._versionedAt = new Date()\n return data\n },\n },\n _versionableOptions: opts,\n }\n\n return behavior as Behavior<VersionableFields>\n}\n\n/**\n * Extract versionable options from an entity's behaviors\n */\nexport function getVersionableOptions(behaviors?: Behavior[]): Required<VersionableOptions> | null {\n if (!behaviors) return null\n const vBehavior = behaviors.find((b) => b.name === 'versionable') as\n | (Behavior & { _versionableOptions?: Required<VersionableOptions> })\n | undefined\n return vBehavior?._versionableOptions ?? null\n}\n"],"mappings":"4CAwBA,MAAM,EAAgD,CACpD,UAAW,GACX,SAAU,EACZ,EAEA,SAAgB,EAAY,EAA2D,CACrF,IAAM,EAAO,CAAE,GAAG,EAAiB,GAAG,CAAQ,EAM9C,GAAI,CAAC,OAAO,UAAU,EAAK,SAAS,GAAK,EAAK,UAAY,EACxD,MAAU,MACR,4BAA4B,EAAK,UAAU,4CAC7C,EAqCF,MAAO,CA9BL,KAAM,cACN,OAAQ,CAQN,SAAU,EAAM,OAAO,CAAE,SAAU,GAAM,QAAS,EAAG,QAAS,GAAM,SAAU,EAAK,CAAC,EACpF,aAAc,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,CAC7C,EACA,MAAO,CACL,aAAc,KAAO,KACnB,EAAK,SAAW,EAChB,EAAK,aAAe,IAAI,KACjB,GAET,aAAc,MAAO,EAAK,KAKxB,EAAK,aAAe,IAAI,KACjB,EAEX,EACA,oBAAqB,CAGT,CAChB,CAKA,SAAgB,EAAsB,EAA6D,CAKjG,OAJK,EACa,EAAU,KAAM,GAAM,EAAE,OAAS,aAGpC,CAAC,EAAE,qBAAuB,KAJlB,IAKzB"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"versionable-CRvV87nZ.d.mts","names":[],"sources":["../src/behaviors/versionable.ts"],"mappings":";;;;KAmBY,iBAAA;EACV,QAAA,EAAU,WAAA;IAAgB,QAAA;EAAA;EAC1B,YAAA,EAAc,SAAS;AAAA;AAAA,iBAQT,WAAA,CAAY,OAAA,GAAU,kBAAA,GAAqB,QAAA,CAAS,iBAAA;;;;iBAqDpD,qBAAA,CAAsB,SAAA,GAAY,QAAA,KAAa,QAAA,CAAS,kBAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"versions-w2ui-Vzb.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 = await 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 = await 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 = await 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 (!(await 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 = await 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 = await 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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"}
|