@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/client.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"sources":["../src/drafts.ts","../src/locale-status.ts","../src/translation-status.ts","../src/client.ts"],"sourcesContent":["/**\n * Draft-overlay operations.\n *\n * Capability: requires the entity to declare `publishable()`. Reads\n * (`getDraft`, `hasDraft`, `countDrafts`, `hasDraftBatch`) silently\n * return falsy when the entity doesn't support drafts; writes\n * (`saveDraft`, `mergeDraft`, `publishDraft`) throw `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport { blockTreeFieldsOf, type FieldConfig } from '@murumets-ee/entity'\nimport type { InferEntityDTO, InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { diffBlocks } from './diff/block-diff.js'\nimport { diffFields } from './diff/field-diff.js'\nimport type { FieldDiff } from './diff/types.js'\nimport { DraftNotFoundError, EntityCapabilityError } from './errors.js'\nimport {\n applyLocaleRoutedUpdate,\n localeFindOptions,\n} from './internal/apply-locale-routed-update.js'\nimport { type ContentContext, getDraftsClient } from './internal/context.js'\nimport { projectToEntityFields } from './internal/projection.js'\nimport { DEFAULT_LOCALE_SENTINEL, validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { DraftsTable } from './schema/drafts.js'\nimport { recordLocaleSave } from './translation-provenance.js'\nimport type { DraftEntry } from './types.js'\nimport { createVersionSnapshot } from './versions.js'\n\ntype DraftRow = NonNullable<Awaited<ReturnType<ReturnType<DraftsTable['makeClient']>['findOne']>>>\n\nfunction toDraftEntry(row: DraftRow): DraftEntry {\n return {\n id: row.id,\n entityId: row.entityId,\n locale: row.locale,\n data: row.data,\n createdBy: row.createdBy,\n createdByName: row.createdByName ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }\n}\n\n/**\n * The live row behind a draft, in the draft's locale — `undefined` when it\n * cannot be read. Never throws: the caller treats an unreadable row as \"no\n * stored copy\", which is the strict answer.\n */\nasync function liveRow<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n): Promise<Record<string, unknown> | undefined> {\n try {\n const row = await ctx.admin.findById(\n entityId,\n locale === DEFAULT_LOCALE_SENTINEL ? {} : { locale },\n )\n return (row as Record<string, unknown> | null) ?? undefined\n } catch (error) {\n ctx.logger?.warn(\n { entity: ctx.entity.name, entityId, error },\n 'draft: the live row could not be read to grandfather its block tree',\n )\n return undefined\n }\n}\n\n/**\n * What a draft save's block trees are judged AGAINST: the stored draft's tree\n * for a field the draft already holds, the live row's for one it does not.\n * The live row is read only in the second case.\n */\nasync function storedTreesFor<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n projected: Record<string, unknown>,\n draftData: unknown,\n): Promise<Record<string, unknown> | undefined> {\n const draft =\n draftData !== null && typeof draftData === 'object'\n ? (draftData as Record<string, unknown>)\n : undefined\n const missing = blockTreeFieldsOf(ctx.entity).some(\n ({ name }) => Object.hasOwn(projected, name) && !Array.isArray(draft?.[name]),\n )\n if (!missing) return draft\n const live = await liveRow(ctx, entityId, locale)\n if (live === undefined) return draft\n return { ...live, ...draft }\n}\n\n/**\n * Save (upsert) a draft for an entity+locale. One draft per\n * entity+locale — overwrites any existing draft.\n *\n * `data` is projected through the entity's declared field whitelist\n * BEFORE it is stored (same `projectToEntityFields` call `publishDraft`\n * makes on the way out). Storing the caller's JSON verbatim let a draft\n * carry keys the entity never declared, identity/audit columns and\n * `internal: true` fields; publishing filtered them, but `getDraft` and\n * `compareDraftWithCurrent` echoed them straight back to the admin UI,\n * and the drafts table accumulated arbitrary caller-controlled JSON\n * (`plan/admin-api-hardening` F002). Validating at the write closes the\n * asymmetry — the stored draft is now always a subset of what the entity\n * can actually hold.\n */\nexport async function saveDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n data: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>>> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const projected = projectToEntityFields(ctx.entity, data as Record<string, unknown>)\n\n // `projectToEntityFields` is a field-NAME whitelist — it never inspects a\n // value, so a `field.blockTree()` column in a draft took arbitrary JSON, and\n // `editor-state` hands the stored draft back as the CANVAS BASE STATE\n // (editor F002). Publishing is already gated by the entity write path, but\n // waiting until publish means the editor loads a tree it cannot represent.\n // Same validator, same judgement — asked here because the draft blob carries\n // no Zod schema of its own.\n ctx.admin.assertBlockTreesValid(projected)\n // …and the STRICT judgement for a token-derived caller (agent-access D014):\n // a draft is published later by whoever presses Publish, so a tree a machine\n // could not store directly must not be parked here for a person to promote.\n // F116, same reasoning — and this is precisely the write path the finding\n // was filed against: dava's home-page DRAFT already holds a mismatched\n // media reference. Grandfathering against the draft ALREADY STORED (rather\n // than validating strictly) is what keeps that draft's next autosave\n // working instead of turning \"a broken image\" into \"a draft that can never\n // be saved again\".\n //\n // The lookup is skipped entirely when the PROJECTED payload carries no\n // block-tree field at all — `assertMediaKindsValid` would no-op anyway\n // (same optimization as `saveTranslation`'s), and most entities with\n // drafts have no block-tree field to begin with.\n const hasBlockTreeValue = blockTreeFieldsOf(ctx.entity).some(({ name }) =>\n Object.hasOwn(projected, name),\n )\n const existing = hasBlockTreeValue ? await client.findOne({ entityId, locale }) : null\n // Grandfathered against the draft already stored, like the media-kind check\n // below: a tree that was there before this save is not what this save adds.\n // With no draft yet, against the live row — a first draft of a document\n // whose live tree holds a block no longer placeable there must still save\n // (davatoidud-toolkit D006).\n await ctx.admin.assertBlockTreesFitDocument(\n projected,\n () => storedTreesFor(ctx, entityId, locale, projected, existing?.data),\n { loadAreaRow: () => liveRow(ctx, entityId, locale) },\n )\n await ctx.admin.assertMediaKindsValid(\n projected,\n existing?.data as Record<string, unknown> | undefined,\n )\n\n const now = new Date()\n const createdBy = user?.id ?? ''\n const createdByName = user?.name ?? null\n\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: projected,\n createdBy,\n createdByName,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only update the mutable fields — preserve createdBy/createdAt.\n set: { data: projected, updatedAt: now },\n },\n )\n\n // What this locale was saved from (agent-access D017) — never fails the save.\n await recordLocaleSave({\n entityName: ctx.entity.name,\n entityId,\n locale,\n userId: user?.id,\n draftData: (row.data as Record<string, unknown> | undefined) ?? {},\n })\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * The keys that move a document's canonical URL in the patterns the toolkit\n * ships (`[slug]`, `[parent.path]`) plus the slug mode that decides whether a\n * clash is refused or auto-suffixed. A merge naming one of them, on a ROUTABLE\n * entity, runs the path-conflict check (see `mergeDraft`); any other merge\n * skips its lookups — the staged draft, the row, and `toolkit_paths` (twice\n * under a parent).\n *\n * A pattern built from another field (`/[category]/[slug]`) is not covered:\n * staging only `category` is refused at Publish rather than here.\n */\nconst PATH_KEYS = ['slug', 'slugMode', 'parent'] as const\n\n/**\n * Stage a PARTIAL change: merge `patch` into the entity+locale draft, creating\n * the draft if there is none. Keys not in `patch` are left exactly as the draft\n * (or, absent a draft key, the live row) has them.\n *\n * This is the write every editing surface uses since editor D019 — \"every edit\n * on every surface saves to the document's one shared draft\". `saveDraft`\n * REPLACES the blob, which is right for a surface that owns the whole document\n * (the menu editor) and wrong for one that owns a few fields of it: page\n * settings saving `title` through `saveDraft` would erase the block edits the\n * canvas staged a second earlier. A draft is therefore a patch over the row, and\n * every reader that shows editable values reads row ⊕ draft.\n *\n * Judged like the entity PATCH it replaces, BEFORE it is stored:\n * - projected through the field whitelist (`internal`, undeclared keys dropped)\n * - `assertBlockTreesValid` + `assertMediaKindsValid` (grandfathered against the\n * stored draft, same as `saveDraft`)\n * - `assertUpdateValid` — the entity's own update schema. `saveDraft` never ran\n * it, which was harmless while the form's Publish still PATCHed; moving a\n * surface into the draft must not move its refusal to Publish, where the\n * author is no longer looking at the field.\n * - `assertPathAvailable` — the path-conflict guard, as a dry run, when the\n * merge moves the URL (see {@link PATH_KEYS}) on a routable entity (F150).\n *\n * 🔴 ONE STATEMENT, not read-modify-write. The merge is `data || patch` inside\n * `ON CONFLICT DO UPDATE` (`TableClient.upsert`'s `mergeJsonb`), so it cannot\n * lose a concurrent write: a read-then-upsert that interleaved with op-commit's\n * or a bound field's stage would write back the draft as it was read — silently\n * reverting the other writer's change. `writeDraft` merges the same way, so\n * neither can erase the other. Top-level `||`, a SHALLOW merge.\n *\n * Resolves `null` when the projected patch is EMPTY and no draft exists: nothing\n * the entity declares was sent, so nothing is staged and no empty draft row is\n * created.\n *\n * ⚠️ The rest of the write path's hooks — `pathParent()`'s cycle check, the\n * partial-unique slug index, `sluggable()`'s regeneration — still run only at\n * Publish, inside its transaction, where a refusal stops the whole publish. The\n * path check here answers \"as of now\"; Publish runs the real guard again.\n */\nexport async function mergeDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n patch: InferUpdateInput<F>,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n\n const projected = projectToEntityFields(ctx.entity, patch as Record<string, unknown>)\n // Nothing the entity declares: stage nothing. Upserting `{}` would CREATE an\n // empty draft, and the document would then read \"Draft saved · not live\n // yet\" about a change that does not exist.\n if (Object.keys(projected).length === 0) {\n const current = await client.findOne({ entityId, locale })\n return current ? (toDraftEntry(current) as DraftEntry<InferUpdateInput<F>>) : null\n }\n ctx.admin.assertBlockTreesValid(projected)\n ctx.admin.assertUpdateValid(projected)\n const hasBlockTreeValue = blockTreeFieldsOf(ctx.entity).some(({ name }) =>\n Object.hasOwn(projected, name),\n )\n const touchesPath =\n ctx.entity.frontend?.path !== undefined &&\n PATH_KEYS.some((key) => Object.hasOwn(projected, key))\n const existing =\n hasBlockTreeValue || touchesPath ? await client.findOne({ entityId, locale }) : null\n // The contextual judgement — the document's area for everyone, the strict\n // tree for a token-derived caller (agent-access D014) — with the stored\n // draft, or the live row when there is none, to grandfather against. See\n // `saveDraft`.\n await ctx.admin.assertBlockTreesFitDocument(\n projected,\n () => storedTreesFor(ctx, entityId, locale, projected, existing?.data),\n { loadAreaRow: () => liveRow(ctx, entityId, locale) },\n )\n await ctx.admin.assertMediaKindsValid(\n projected,\n existing?.data as Record<string, unknown> | undefined,\n )\n // A staged slug that another document's URL already holds is refused NOW,\n // beside the field the author typed it in — not first at Publish (editor\n // PR60, F150). Checked against what the draft will hold after this merge,\n // since an earlier stage may have set `slugMode` or `parent`.\n if (touchesPath) {\n const staged = {\n ...((existing?.data as Record<string, unknown> | undefined) ?? {}),\n ...projected,\n }\n if (locale === DEFAULT_LOCALE_SENTINEL) {\n await ctx.admin.assertPathAvailable(entityId, staged)\n } else {\n // Checked the way Publish writes it: `updateForLocale` sends a locale's\n // TRANSLATABLE fields through that locale's guard and the rest (a\n // `parent`, a non-translatable slug) through `update()`, whose guard\n // checks the default locale. One guard over everything would test a\n // shared `parent` against the wrong locale's URLs.\n const fields = ctx.entity.allFields as Record<string, { translatable?: boolean } | undefined>\n const translatable: Record<string, unknown> = {}\n const shared: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(staged)) {\n if (fields[key]?.translatable === true) translatable[key] = value\n else shared[key] = value\n }\n const moves = (part: Record<string, unknown>) =>\n PATH_KEYS.some((key) => Object.hasOwn(part, key) && Object.hasOwn(projected, key))\n if (moves(translatable)) await ctx.admin.assertPathAvailable(entityId, translatable, locale)\n if (moves(shared)) await ctx.admin.assertPathAvailable(entityId, shared)\n }\n }\n\n const now = new Date()\n const row = await client.upsert(\n {\n entityId,\n locale,\n data: projected,\n createdBy: user?.id ?? '',\n createdByName: user?.name ?? null,\n createdAt: now,\n updatedAt: now,\n },\n { target: ['entityId', 'locale'], set: { updatedAt: now }, mergeJsonb: ['data'] },\n )\n // What this locale was saved from (agent-access D017) — never fails the save.\n await recordLocaleSave({\n entityName: ctx.entity.name,\n entityId,\n locale,\n userId: user?.id,\n draftData: (row.data as Record<string, unknown> | undefined) ?? {},\n })\n return toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>\n}\n\n/**\n * Get the draft for an entity+locale, or null if none exists (or the\n * entity doesn't support drafts).\n */\nexport async function getDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<DraftEntry<InferUpdateInput<F>> | null> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return null\n const row = await client.findOne({ entityId, locale })\n return row ? (toDraftEntry(row) as DraftEntry<InferUpdateInput<F>>) : null\n}\n\n/**\n * Delete (discard) a draft for an entity+locale. Returns whether\n * anything was deleted; safely no-ops on entities without drafts.\n */\nexport async function deleteDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return false\n const deleted = await client.deleteMany({ entityId, locale })\n // Discarding a draft changes the locale's working text back to the row's\n // (agent-access D017) — record it, or a discarded SOURCE edit keeps every\n // translation reading \"outdated\". `reverted`: nobody wrote a word, so an\n // agent's discard is not machine output. What this does NOT do: un-mark a\n // locale whose discarded draft was a machine translation — one row per locale\n // cannot say what the live row underneath was, so it keeps reading \"not\n // reviewed\" until someone reviews it or saves it.\n if (deleted.length > 0) {\n await recordLocaleSave({ entityName: ctx.entity.name, entityId, locale, reverted: true })\n }\n return deleted.length > 0\n}\n\n/**\n * Publish a draft: make THIS document's staged changes live.\n *\n * \"Publish\" means one thing on every surface (editor D019): the draft's fields\n * are written to the row for the draft's locale AND the row is put live. The\n * draft is then gone and, if versionable, a version snapshot records what went\n * live.\n *\n * Locale routing mirrors the admin PATCH handler (and the taxonomy fix\n * in 10a7cc2b): a default-locale draft writes the base row; a\n * non-default-locale draft routes translatable fields to\n * `saveTranslation` (→ `{entity}_translations`), `status` to the\n * per-locale status table, and only non-translatable fields (which have\n * no per-locale home) to the base row. Without this split, publishing\n * an `et` draft overwrote the default-locale base row wholesale.\n *\n * ## ONE TRANSACTION (editor PR60, R055 §4)\n *\n * The row write, the locale status, `_overrides`, the draft delete and the\n * version snapshot commit together or not at all. They used to be five separate\n * statements, so a failure part-way left a document whose content was live and\n * whose draft still claimed to be pending — or, worse, whose draft was gone while\n * the row write had not landed.\n *\n * The draft is CLAIMED first, by deleting it and publishing what the delete\n * returned. The delete takes the row lock, so an op-commit that is mid-flight on\n * the same draft either committed before (and its edit is in what we publish) or\n * blocks until we commit and then retries its SERIALIZABLE transaction — or, if it\n * had not read the draft yet, inserts a fresh draft holding only its own patch.\n * Either way it does not write a stale copy back over the published one. The editor also flushes and awaits its own in-flight saves\n * before it calls this, so the lock is the backstop, not the mechanism.\n *\n * Path-index and write notifications fire after COMMIT, not inside it —\n * `AdminClient.afterCommit` defers them on a tracked transaction.\n */\nexport async function publishDraft<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n user?: { id: string; name?: string },\n): Promise<InferEntityDTO<F>> {\n validateLocale(locale)\n if (!(await getDraftsClient(ctx))) {\n throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n }\n\n // Previous state for the version delta — locale-merged, so the delta compares\n // against what this locale's readers actually saw. Read BEFORE the\n // transaction: it is the committed state the publish replaces.\n const previousState: InferEntityDTO<F> | null = ctx.versionableOptions\n ? await ctx.admin.findById(entityId, localeFindOptions(locale))\n : null\n\n return ctx.db.transaction(async (tx) => {\n // Every table client below binds to `tx` through this context — drafts,\n // locale status, versions and the `_version` column write.\n const txCtx: ContentContext<F> = { ...ctx, db: tx }\n const draftsClient = await getDraftsClient(txCtx)\n if (!draftsClient) throw new EntityCapabilityError(ctx.entity.name, 'drafts')\n const [claimed] = await draftsClient.deleteMany({ entityId, locale })\n if (!claimed) {\n throw new DraftNotFoundError(entityId, locale)\n }\n\n // `?? {}` because the column is `jsonb NOT NULL`, and that forbids SQL NULL\n // while permitting the JSON literal `null` — which deserializes to JS `null`.\n // A bare cast then made `draftData.status` a TypeError, so a malformed row\n // 500'd the publish route instead of taking the ordinary \"nothing declared,\n // publish the row as-is\" path below. Nothing in this package writes such a\n // row; a seed, a migration or a hand-edit can.\n const claimedData = (claimed.data ?? {}) as Record<string, unknown>\n // `publishedAt` is Publish's to set, like `status` below: a staged value\n // would let whoever wrote the draft back- or forward-date the publication.\n // publishable()'s hook fills it when the UPDATE carries none — which, with\n // the staged value dropped, is every publish: it records the LATEST\n // publication, not the first.\n const { publishedAt: _stagedPublishedAt, ...draftData } = claimedData\n // \"Publish\" means MAKE LIVE, always — the status is not the draft's to\n // decide. Block-editor drafts carry no `status` at all, and before D019 a\n // form-saved draft could carry `status: 'draft'`, which then won: Publish\n // wrote the content and left the document unpublished, so the same button\n // meant \"publish\" on one surface and \"stage\" on another. The publishable()\n // behavior auto-sets `publishedAt` on the transition. The publish-permission\n // gate lives in the admin route, not here.\n const updateData = projectToEntityFields(ctx.entity, { ...draftData, status: 'published' })\n const result = await applyLocaleRoutedUpdate(txCtx, entityId, updateData, locale, tx)\n\n // F009 — promote the page's chrome overrides.\n //\n // `projectToEntityFields` drops every `internal: true` field, deliberately, so\n // `_overrides` cannot ride the generic promotion. That guard is NOT relaxed:\n // this is one named field, written through the trusted `updateInternal` path\n // that `with-overrides.ts` documents as \"the only path that can mutate it\".\n // Before F009 that call site did not exist, so an override survived in the\n // draft buffer and never reached the column — the op committed, the canvas\n // updated, and a reload brought the hidden header back.\n //\n // AFTER the main update, so the override never lands against content that\n // failed to — and inside the same transaction, so neither lands alone.\n // `_overrides` is not translatable, so it belongs on the base row for every\n // locale.\n const overrides = readOverridesForPublish(ctx.entity, draftData)\n if (overrides !== undefined) {\n await ctx.admin.updateInternal(entityId, { _overrides: overrides }, { tx })\n }\n\n if (ctx.versionableOptions) {\n await createVersionSnapshot(txCtx, entityId, result, previousState, locale, user)\n }\n\n return result\n })\n}\n\n/**\n * The page's override map from a draft payload, when the entity declares the\n * field and the draft carries one.\n *\n * Returns `undefined` — never `{}` — so an ordinary content publish does not\n * touch the column. Shape-guarded because a draft payload is stored jsonb: an\n * array or a scalar is malformed data, not an override map. ← F009\n */\nfunction readOverridesForPublish(\n entity: { allFields: Record<string, unknown>; behaviors?: readonly { name: string }[] },\n draftData: Record<string, unknown>,\n): Record<string, unknown> | undefined {\n // The BEHAVIOR, not just a field with the right name — a draft payload is\n // stored jsonb and can be crafted or legacy, so this must not become a way\n // to promote arbitrary JSON into an unrelated internal field on an entity\n // that never opted in. Requiring the behavior also means `beforeUpdate` runs\n // `validateOverridesMap` on the way through `updateInternal`.\n if (!(entity.behaviors ?? []).some((b) => b.name === 'withOverrides')) return undefined\n const field = entity.allFields._overrides as { internal?: boolean } | undefined\n if (field === undefined || field.internal !== true) return undefined\n const value = draftData._overrides\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined\n return value as Record<string, unknown>\n}\n\n/**\n * Quick existence check for list badges — does a draft exist?\n */\nexport async function hasDraft(\n ctx: ContentContext,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<boolean> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return false\n return client.exists({ entityId, locale })\n}\n\n/**\n * Count drafts for this entity type, optionally filtered by locale.\n * Single indexed COUNT — used by dashboard widgets.\n */\nexport async function countDrafts(\n ctx: ContentContext,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<number> {\n validateLocale(locale)\n const client = await getDraftsClient(ctx)\n if (!client) return 0\n return client.count({ locale })\n}\n\n/**\n * Check draft existence for multiple entities in one query. Returns\n * a set of entity IDs that have a draft for `locale`.\n */\nexport async function hasDraftBatch(\n ctx: ContentContext,\n entityIds: string[],\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<Set<string>> {\n validateLocale(locale)\n const result = new Set<string>()\n if (entityIds.length === 0) return result\n const client = await getDraftsClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({\n where: { entityId: { in: entityIds }, locale },\n })\n for (const row of rows) {\n result.add(row.entityId as string)\n }\n return result\n}\n\n/**\n * Compare the current draft with the live entity. Returns field-level\n * diffs between the published data and the draft (only for fields\n * present in the draft — missing fields are NOT \"changed to empty\").\n */\nexport async function compareDraftWithCurrent<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale = DEFAULT_LOCALE_SENTINEL,\n): Promise<{\n diffs: FieldDiff[]\n current: InferEntityDTO<F>\n draft: DraftEntry<InferUpdateInput<F>>\n} | null> {\n validateLocale(locale)\n const draft = await getDraft(ctx, entityId, locale)\n if (!draft) return null\n\n // Fetch the live entity with the same locale context as the draft.\n const config = getContentConfig()\n const defaultLocale = config.defaultLocale\n const findOptions =\n locale === DEFAULT_LOCALE_SENTINEL ? { defaultLocale } : { locale, defaultLocale }\n const current = await ctx.admin.findById(entityId, findOptions)\n if (!current) return null\n\n const fieldConfigs = ctx.entity.allFields as Record<string, FieldConfig> | undefined\n const currentRecord = current as Record<string, unknown>\n const draftRecord = draft.data as Record<string, unknown>\n const currentSubset: Record<string, unknown> = {}\n for (const key of Object.keys(draftRecord)) {\n currentSubset[key] = currentRecord[key]\n }\n const diffs = diffFields(currentSubset, draftRecord, fieldConfigs)\n for (const diff of diffs) {\n if (diff.type === 'blockTree') {\n // Normalize a null/undefined side to [] so add/clear of the whole field\n // (null ↔ blockTree[]) still produces a structural diff, not an opaque one.\n diff.blockDiffs = diffBlocks(\n Array.isArray(diff.from) ? diff.from : [],\n Array.isArray(diff.to) ? diff.to : [],\n )\n }\n }\n\n return { diffs, current, draft }\n}\n","/**\n * Per-locale publish status — independent draft/published state for\n * each locale of a publishable + translatable entity.\n *\n * Capability: requires `publishable() + at least one translatable\n * field` (the `_locale_status` table is registered only for that\n * combination). Reads silently return falsy on entities without it;\n * `setLocaleStatus` throws `EntityCapabilityError`.\n */\n\nimport 'server-only'\n\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport type { InferUpdateInput } from '@murumets-ee/entity/admin'\nimport { EntityCapabilityError } from './errors.js'\nimport { type ContentContext, getLocaleStatusClient } from './internal/context.js'\nimport { upsertLocaleStatusRow } from './locale-status-slot.js'\nimport { validateLocale } from './locale-utils.js'\nimport { getContentConfig } from './plugin.js'\nimport type { LocalePublishStatus } from './types.js'\n\n/**\n * Get per-locale publish status overrides for an entity.\n * Missing locale = inherits the base entity status (no row written).\n *\n * **Not permission-gated, deliberately — see {@link getLocaleStatusBatch}.**\n */\nexport async function getLocaleStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<LocalePublishStatus[]> {\n const map = await getLocaleStatusBatch(ctx, [entityId])\n const entry = map.get(entityId)\n if (!entry) return []\n return Object.values(entry)\n}\n\n/**\n * Set per-locale publish status. Upserts into the locale_status\n * table — except when `locale === defaultLocale`, which updates the\n * base entity status column directly (the per-locale table only\n * stores overrides for non-default locales).\n *\n * **Both branches are publish-gated by the entity layer (D041).** The default\n * branch inherits it from `AdminClient.update`; the non-default branch routes\n * through `AdminClient.updateForLocale`, which asserts the SAME\n * `assertPublishAuthority` from the caller's payload before it hands the write\n * to the locale-status writer. This is deliberately a delegation and not a\n * second predicate in this package: `<entity>_locale_status` is the table the\n * public read predicate (`COALESCE(locale_status.status, main.status)`) resolves\n * against, so a direct `TableClient.upsert` here — which is what this function\n * used to do — is a fourth publish door, reachable from any plugin/route/job\n * holding a `ContentClient` (a PUBLIC subpath export) with no `publish` grant\n * ever consulted.\n */\nexport async function setLocaleStatus<F extends Record<string, FieldConfig>>(\n ctx: ContentContext<F>,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n validateLocale(locale)\n const config = getContentConfig()\n\n // Default locale → update base entity status column.\n if (locale === config.defaultLocale) {\n const data: Record<string, unknown> = { status }\n if (status === 'published') data.publishedAt = new Date()\n await ctx.admin.update(entityId, data as InferUpdateInput<F>)\n return\n }\n\n const client = await getLocaleStatusClient(ctx)\n if (!client) {\n // Per-locale status only exists for publishable + translatable\n // entities. Calling on a non-translatable publishable (where the\n // only valid locale is the default) is a programmer error.\n // Checked BEFORE delegating: `updateForLocale` would otherwise fall back to\n // the base `status` column for a missing writer, silently publishing the\n // default locale instead of refusing.\n throw new EntityCapabilityError(ctx.entity.name, 'localeStatus')\n }\n\n // The writer we hand down is the RAW upsert, not this function — the\n // delegation terminates here rather than re-entering `updateForLocale`.\n //\n // Routing through `updateForLocale` rather than calling the upsert directly\n // is what closes the publish door: this branch used to write\n // `<entity>_locale_status` with no permission check at all, so any holder of\n // this public export could make a translation publicly live without\n // `publish`. `updateForLocale` asserts publish authority at the operation\n // entry (D041), so the gate is inherited rather than duplicated here.\n //\n // Widened through `Record<string, unknown>` for the same reason the\n // default-locale branch above does it: `InferUpdateInput<F>` is keyed on the\n // entity's own field map, which a generic `F` cannot be proven to overlap.\n const localeData: Record<string, unknown> = { status }\n await ctx.admin.updateForLocale(entityId, localeData as InferUpdateInput<F>, locale, {\n defaultLocale: config.defaultLocale,\n localeStatusWriter: (id, loc, s) => upsertLocaleStatusRow(client, id, loc, s),\n })\n}\n\n/**\n * Get per-locale publish status for multiple entities in one query.\n *\n * ## Why this read is NOT permission-gated (and what would gate it)\n *\n * It returns publish-state METADATA only — `{ locale, status, publishedAt }` —\n * never field data, so it is not a content-disclosure door the way the write\n * side above is a publish door. What it does expose is an existence oracle for\n * unpublished TRANSLATIONS: a principal without `view_unpublished` learns that\n * `et` is a draft, which `QueryClient` would have hidden. The correct predicate\n * is therefore `view_unpublished` (D017), NOT `publish`.\n *\n * A CodeRabbit pass proposed gating it on exactly that predicate. Deliberately\n * declined, on the strength of **F063**: `view_unpublished` governs `QueryClient`\n * reads — the PUBLIC delivery surface — and is not a disclosure boundary over\n * the admin API, where `AdminClient` shows editors their own drafts by design.\n * `ContentClient` is an in-process server-side API whose consumers are plugins,\n * routes and jobs on the admin side, so gating it here would extend the control\n * onto the surface F063 explicitly recommended NOT extending it to. The concrete\n * cost would be an editor lacking `view_unpublished` no longer able to see that\n * their OWN translation is still a draft.\n *\n * The asymmetry with the write side above is real, not convenient:\n * `setLocaleStatus` MINTS public visibility, so it is gated regardless of which\n * surface calls it. This read hands publish-state metadata to a caller who\n * already holds `view` on the row — they needed its id to ask.\n *\n * It is not applied here because the predicate (`mayViewUnpublishedEntity`) is\n * module-private to `@murumets-ee/entity` — re-deriving it in this package\n * would create exactly the second, drifting copy of a security predicate that\n * D041 exists to eliminate, and there is no read-path equivalent of\n * `updateForLocale` to delegate to. The one in-repo caller\n * (`admin-ui`'s versions route) is already gated by `checkPermission`. Closing\n * this properly means exporting the predicate from `@murumets-ee/entity` and\n * dropping non-`published` rows for principals without it — tracked as the\n * read-side sibling of F060.\n */\nexport async function getLocaleStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, Record<string, LocalePublishStatus>>> {\n const result = new Map<string, Record<string, LocalePublishStatus>>()\n if (entityIds.length === 0) return result\n const client = await getLocaleStatusClient(ctx)\n if (!client) return result\n\n const rows = await client.findMany({ where: { entityId: { in: entityIds } } })\n for (const r of rows) {\n const eid = r.entityId as string\n const locale = r.locale as string\n let entry = result.get(eid)\n if (!entry) {\n entry = {}\n result.set(eid, entry)\n }\n entry[locale] = {\n locale,\n status: r.status as 'draft' | 'published',\n publishedAt: (r.publishedAt as Date | null) ?? null,\n }\n }\n return result\n}\n","/**\n * Translation status reporting — per-locale completion of translatable\n * fields for one entity, batches of entities, or the whole entity type.\n *\n * Capability: requires the entity to have at least one translatable\n * field. All methods return falsy / empty when the entity has no\n * translatable fields (read-only and defensive).\n */\n\nimport 'server-only'\n\nimport { getContext, requireSiteFor } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { buildPartitionCondition, type FieldConfig } from '@murumets-ee/entity'\nimport { and, inArray } from 'drizzle-orm'\nimport { InvalidLocaleError } from './errors.js'\nimport type { ContentContext } from './internal/context.js'\nimport { getContentConfig } from './plugin.js'\nimport { getTranslationProvenanceBatch, translationFreshness } from './translation-provenance.js'\nimport type { TranslationReport, TranslationStatus } from './types.js'\n\n/**\n * The locales a status report covers and the default it skips: the request's\n * — which the admin narrows to its SITE's locales and default (plan `multisite`\n * phase 03) — else the configured content locales.\n */\nfunction statusLocales(): { locales: { code: string }[]; defaultLocale: string } {\n const config = getContentConfig()\n const ctx = getContext()\n const codes = ctx?.availableLocales ?? config.locales.map((l) => l.code)\n return {\n locales: codes.map((code) => ({ code })),\n defaultLocale: ctx?.defaultLocale ?? config.defaultLocale,\n }\n}\n\n/**\n * For one entity, compute per-locale completion status across every\n * translatable field. Default-locale rows are skipped.\n */\nfunction computeStatusForEntity(\n translatableFields: string[],\n translatedByLocale: Map<string, Record<string, unknown>> | undefined,\n locales: { code: string }[],\n defaultLocale: string,\n): TranslationStatus {\n const status: TranslationStatus = {}\n for (const locale of locales) {\n if (locale.code === defaultLocale) continue\n const translated = translatedByLocale?.get(locale.code) ?? {}\n const missing: string[] = []\n for (const fieldName of translatableFields) {\n if (translated[fieldName] === undefined || translated[fieldName] === null) {\n missing.push(fieldName)\n }\n }\n status[locale.code] = {\n complete: missing.length === 0,\n total: translatableFields.length,\n translated: translatableFields.length - missing.length,\n missing,\n }\n }\n return status\n}\n\n/**\n * Get translation status for an entity — per-locale completion report.\n */\nexport async function getTranslationStatus(\n ctx: ContentContext,\n entityId: string,\n): Promise<TranslationStatus> {\n // Single-entity is the batch path with one ID — keeps the two\n // codepaths from drifting.\n const map = await getTranslationStatusBatch(ctx, [entityId])\n return map.get(entityId) ?? {}\n}\n\n/**\n * Get translation status for multiple entities in a single query.\n * O(1) DB roundtrips regardless of `entityIds.length`.\n */\nexport async function getTranslationStatusBatch(\n ctx: ContentContext,\n entityIds: string[],\n): Promise<Map<string, TranslationStatus>> {\n const result = new Map<string, TranslationStatus>()\n if (entityIds.length === 0) return result\n\n const { locales, defaultLocale } = statusLocales()\n\n const translatableFields = Object.entries(ctx.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, f]) => f.translatable)\n .map(([name]) => name)\n if (translatableFields.length === 0) return result\n\n // Translation tables are generated in @murumets-ee/entity (with FK\n // refs); accessed via schemaRegistry + direct Drizzle here.\n const translationTable = schemaRegistry.get(`${ctx.entity.name}_translations`)\n if (!translationTable) return result\n\n // A site-bound entity's translation rows are confined to the request's\n // site (multisite D003): ids alone are no boundary — a caller holding\n // another site's id must learn nothing about its translations.\n const site = await requireSiteFor(ctx.entity)\n const byIds = inArray(translationTable.entityId, entityIds)\n const translations = await ctx.db\n .select()\n .from(translationTable)\n .where(\n site !== undefined\n ? and(byIds, buildPartitionCondition(translationTable, { site }, ctx.entity.name))\n : byIds,\n )\n\n // Group: entityId → locale → { fieldName: value }\n const translationsByEntity = new Map<string, Map<string, Record<string, unknown>>>()\n for (const t of translations) {\n const row = t as Record<string, unknown>\n const eid = row.entityId as string\n const locale = row.locale as string\n const fieldValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n fieldValues[fieldName] = row[fieldName]\n }\n let entry = translationsByEntity.get(eid)\n if (!entry) {\n entry = new Map()\n translationsByEntity.set(eid, entry)\n }\n entry.set(locale, fieldValues)\n }\n\n // What each locale was last saved from (agent-access D017) — one more query\n // for the whole batch, a comparison of stored hashes per locale after that.\n const provenance = await getTranslationProvenanceBatch(ctx.entity.name, entityIds, ctx.db)\n\n for (const entityId of entityIds) {\n const status = computeStatusForEntity(\n translatableFields,\n translationsByEntity.get(entityId),\n locales,\n defaultLocale,\n )\n const byLocale = provenance.get(entityId)\n for (const [locale, entry] of Object.entries(status)) {\n const freshness = translationFreshness(byLocale, locale)\n if (freshness) entry.freshness = freshness\n }\n result.set(entityId, status)\n }\n\n return result\n}\n\nconst EMPTY_REPORT: TranslationReport = {\n total: 0,\n complete: 0,\n partial: 0,\n missing: 0,\n outdated: 0,\n machineUnreviewed: 0,\n}\n\n/**\n * Roll up translation status across every entity of this type.\n *\n * Defaults to the first non-default configured locale; pass\n * `options.locale` to target a specific one. Returns counts of\n * complete / partial / missing entities for that locale.\n */\nexport async function getTranslationReport(\n ctx: ContentContext,\n options?: { locale?: string },\n): Promise<TranslationReport> {\n const { locales, defaultLocale } = statusLocales()\n const targetLocale = options?.locale ?? locales.find((l) => l.code !== defaultLocale)?.code\n if (!targetLocale) return { ...EMPTY_REPORT }\n // A locale the site does not serve has no report on that site.\n if (!locales.some((l) => l.code === targetLocale)) {\n throw new InvalidLocaleError(\n targetLocale,\n locales.map((l) => l.code),\n )\n }\n\n const entities = await ctx.admin.findMany()\n if (entities.length === 0) return { ...EMPTY_REPORT }\n\n // Single batched query covers every entity at once.\n const statusMap = await getTranslationStatusBatch(\n ctx,\n entities.map((e) => e.id),\n )\n\n let complete = 0\n let partial = 0\n let missing = 0\n let outdated = 0\n let machineUnreviewed = 0\n for (const entity of entities) {\n const status = statusMap.get(entity.id)\n const localeStatus = status?.[targetLocale]\n if (!localeStatus || localeStatus.translated === 0) missing++\n else if (localeStatus.complete) complete++\n else partial++\n // Independent of the three above: a draft translation is \"missing\" (not\n // live yet) and can be machine-unreviewed, or outdated, at the same time.\n if (localeStatus?.freshness?.outdated === true) outdated++\n if (localeStatus?.freshness?.machineUnreviewed === true) machineUnreviewed++\n }\n\n return { total: entities.length, complete, partial, missing, outdated, machineUnreviewed }\n}\n","/**\n * ContentClient — thin shell over per-feature modules.\n *\n * Wraps AdminClient with version history (`./versions.ts`), draft\n * overlays (`./drafts.ts`), translation status reporting\n * (`./translation-status.ts`), per-locale publish status\n * (`./locale-status.ts`), and block-layout operations (`./layout.ts`).\n *\n * The class is generic over the entity's `AllFields` map: when\n * constructed with a typed entity, every method's input/output is\n * inferred end-to-end. When constructed with a runtime `Entity`\n * (admin-ui's heterogeneous entity loops), the generic defaults to\n * `Record<string, FieldConfig>` and methods accept/return the loose\n * shape — same behavior as the pre-generic version.\n *\n * Usage:\n * import { createContentClient } from '@murumets-ee/content/client'\n * const pages = createContentClient(Page)\n * await pages.create({ title: 'Hello', layout: [...] })\n */\n\nimport 'server-only'\n\nimport { getApp, type Logger } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type {\n AdminClient,\n FindManyOptions,\n InferCreateInput,\n InferEntityDTO,\n InferUpdateInput,\n} from '@murumets-ee/entity/admin'\nimport { getVersionableOptions } from './behaviors/versionable.js'\nimport {\n compareDraftWithCurrent,\n countDrafts,\n deleteDraft,\n getDraft,\n hasDraft,\n hasDraftBatch,\n mergeDraft,\n publishDraft,\n saveDraft,\n} from './drafts.js'\nimport { type ContentContext, createContentContext } from './internal/context.js'\nimport { calculateDelta } from './internal/projection.js'\nimport { getLocaleStatus, getLocaleStatusBatch, setLocaleStatus } from './locale-status.js'\nimport {\n getTranslationReport,\n getTranslationStatus,\n getTranslationStatusBatch,\n} from './translation-status.js'\nimport {\n compareVersions,\n createVersionSnapshot,\n getVersion,\n getVersions,\n pruneVersions,\n restoreVersion,\n saveVersionSnapshot,\n writeVersionColumn,\n} from './versions.js'\n\nexport interface ContentClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n}\n\nexport class ContentClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private ctx: ContentContext<AllFields>\n\n constructor(config: ContentClientConfig<AllFields>) {\n const versionableOptions = getVersionableOptions(config.entity.behaviors)\n this.ctx = createContentContext(config, versionableOptions)\n }\n\n // -----------------------------------------------------------------\n // CRUD (delegated to AdminClient + version snapshots)\n // -----------------------------------------------------------------\n\n async create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n const result = await this.ctx.admin.create(data)\n if (this.ctx.versionableOptions) {\n await saveVersionSnapshot(this.ctx, result.id, result, null, false)\n }\n return result\n }\n\n findById(id: string, options?: { locale?: string }): Promise<InferEntityDTO<AllFields> | null> {\n return this.ctx.admin.findById(id, options)\n }\n\n findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n return this.ctx.admin.findMany(options)\n }\n\n /**\n * Base-row update + version snapshot. Subject to AdminClient's\n * cross-locale write guard (SD021): under a non-default request-context\n * locale, translatable fields throw `CrossLocaleWriteError` — that's\n * enforcement, not a gap. Locale-belonging writes go through the draft\n * flow (`saveDraft` → `publishDraft`) or `admin.updateForLocale` directly.\n */\n async update(id: string, data: InferUpdateInput<AllFields>): Promise<InferEntityDTO<AllFields>> {\n let previousState: InferEntityDTO<AllFields> | null = null\n if (this.ctx.versionableOptions) {\n previousState = await this.ctx.admin.findById(id)\n }\n\n const result = await this.ctx.admin.update(id, data)\n\n if (this.ctx.versionableOptions && previousState) {\n const delta = calculateDelta(previousState, result)\n const version = await saveVersionSnapshot(this.ctx, id, result, delta, false)\n if (version !== undefined) {\n await writeVersionColumn(this.ctx, id, version)\n await pruneVersions(this.ctx, id)\n return { ...result, _version: version } as InferEntityDTO<AllFields>\n }\n await pruneVersions(this.ctx, id)\n }\n\n return result\n }\n\n delete(id: string): Promise<unknown> {\n return this.ctx.admin.delete(id)\n }\n\n // -----------------------------------------------------------------\n // Translation delegation\n // -----------------------------------------------------------------\n\n saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>) {\n return this.ctx.admin.saveTranslation(entityId, locale, translations)\n }\n\n deleteTranslation(entityId: string, locale?: string) {\n return this.ctx.admin.deleteTranslation(entityId, locale)\n }\n\n // -----------------------------------------------------------------\n // Version history\n // -----------------------------------------------------------------\n\n getVersions(entityId: string, options?: { limit?: number; offset?: number; locale?: string }) {\n return getVersions(this.ctx, entityId, options)\n }\n\n getVersion(entityId: string, version: number, locale?: string) {\n return getVersion(this.ctx, entityId, version, locale)\n }\n\n compareVersions(entityId: string, v1: number, v2: number, locale?: string) {\n return compareVersions(this.ctx, entityId, v1, v2, locale)\n }\n\n restoreVersion(entityId: string, version: number, locale?: string) {\n return restoreVersion(this.ctx, entityId, version, locale)\n }\n\n pruneVersions(entityId: string, locale?: string) {\n return pruneVersions(this.ctx, entityId, locale)\n }\n\n createVersionSnapshot(\n entityId: string,\n currentData: Record<string, unknown>,\n previousData?: Record<string, unknown> | null,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return createVersionSnapshot(this.ctx, entityId, currentData, previousData, locale, user)\n }\n\n // -----------------------------------------------------------------\n // Translation status\n // -----------------------------------------------------------------\n\n getTranslationStatus(entityId: string) {\n return getTranslationStatus(this.ctx, entityId)\n }\n\n getTranslationStatusBatch(entityIds: string[]) {\n return getTranslationStatusBatch(this.ctx, entityIds)\n }\n\n getTranslationReport(options?: { locale?: string }) {\n return getTranslationReport(this.ctx, options)\n }\n\n // -----------------------------------------------------------------\n // Per-locale publish status\n // -----------------------------------------------------------------\n\n getLocaleStatus(entityId: string) {\n return getLocaleStatus(this.ctx, entityId)\n }\n\n setLocaleStatus(entityId: string, locale: string, status: 'draft' | 'published') {\n return setLocaleStatus(this.ctx, entityId, locale, status)\n }\n\n getLocaleStatusBatch(entityIds: string[]) {\n return getLocaleStatusBatch(this.ctx, entityIds)\n }\n\n // -----------------------------------------------------------------\n // Drafts\n // -----------------------------------------------------------------\n\n saveDraft(\n entityId: string,\n data: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return saveDraft(this.ctx, entityId, data, locale, user)\n }\n\n mergeDraft(\n entityId: string,\n patch: InferUpdateInput<AllFields>,\n locale?: string,\n user?: { id: string; name?: string },\n ) {\n return mergeDraft(this.ctx, entityId, patch, locale, user)\n }\n\n getDraft(entityId: string, locale?: string) {\n return getDraft(this.ctx, entityId, locale)\n }\n\n deleteDraft(entityId: string, locale?: string) {\n return deleteDraft(this.ctx, entityId, locale)\n }\n\n publishDraft(entityId: string, locale?: string, user?: { id: string; name?: string }) {\n return publishDraft(this.ctx, entityId, locale, user)\n }\n\n hasDraft(entityId: string, locale?: string) {\n return hasDraft(this.ctx, entityId, locale)\n }\n\n countDrafts(locale?: string) {\n return countDrafts(this.ctx, locale)\n }\n\n hasDraftBatch(entityIds: string[], locale?: string) {\n return hasDraftBatch(this.ctx, entityIds, locale)\n }\n\n compareDraftWithCurrent(entityId: string, locale?: string) {\n return compareDraftWithCurrent(this.ctx, entityId, locale)\n }\n}\n\n/**\n * Factory — creates a ContentClient for an entity. Must be called\n * after `createApp()`. Generic flows from the entity definition:\n * `createContentClient(Article)` returns a `ContentClient<typeof\n * Article['allFields']>` typed end-to-end.\n */\nexport function createContentClient<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n): ContentClient<AllFields> {\n const admin = createAdminClient(entity)\n return new ContentClient<AllFields>({\n admin,\n entity,\n logger: getApp().logger.child({ content: entity.name }),\n })\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"content-config-store-8omQF5p5.mjs","names":[],"sources":["../src/content-config-store.ts"],"sourcesContent":["/**\n * The `content()` plugin config store — a leaf module holding ONLY the globalThis\n * slot + accessors, with **no** schema / plugin imports.\n *\n * Extracted from `plugin.ts` (which also pulls `defineTable`-backed schema modules)\n * so that `locale-registry.ts` (the SD006 resolvers, re-exported from the **main entry**)\n * can read the shipped locales WITHOUT dragging the schema/table graph into it — that's\n * what stops `@murumets-ee/content`'s main index from transitively evaluating\n * `schema/locks.ts` at import time (the bug that broke the api-handler unit tests).\n *\n * `locale-utils.ts` (validateLocale) deliberately keeps importing `getContentConfig`\n * from `plugin.js`: it is NOT on the main-index import graph (only `content/query` +\n * `content/client`, both already `server-only`, so there's no bundle/jiti benefit), and\n * its consumers' unit tests mock `plugin.js` — repointing it here would ripple through\n * ~7 test mocks for no runtime gain.\n */\n\nimport type { ContentConfig } from './types.js'\n\n/**\n * The active config lives on `globalThis` keyed by `Symbol.for(...)`, NOT in a\n * module-level binding — same Turbopack-dev module-duplication hazard as the\n * content-tables registry (see schema/registry.ts): a lazily-compiled route chunk\n * can get a SECOND instance of this module whose `let` was never assigned, and\n * `getContentConfig()` throws \"not initialized\" on a perfectly initialized app.\n * `Symbol.for` keys are process-global, so every duplicate instance reads the same\n * slot. (pr-review pass-1 M4 — load-bearing on the publish + locale-routing path.)\n */\nconst CONFIG_KEY = Symbol.for('@murumets-ee/content:content-config')\n\ninterface GlobalWithContentConfig {\n [CONFIG_KEY]?: ContentConfig\n}\n\n/** Store the resolved content config (called once by the plugin's `init`). */\nexport function setContentConfig(config: ContentConfig): void {\n ;(globalThis as GlobalWithContentConfig)[CONFIG_KEY] = config\n}\n\n/**\n * Get the content configuration. Throws if the plugin hasn't initialized.\n */\nexport function getContentConfig(): ContentConfig {\n const config = (globalThis as GlobalWithContentConfig)[CONFIG_KEY]\n if (!config) {\n throw new Error(\n '@murumets-ee/content plugin not initialized. Add content() to your plugins array.',\n )\n }\n return config\n}\n"],"mappings":"AA4BA,MAAM,EAAa,OAAO,IAAI,qCAAqC,EAOnE,SAAgB,EAAiB,EAA6B,CAC3D,WAAwC,GAAc,CACzD,CAKA,SAAgB,GAAkC,CAChD,IAAM,EAAU,WAAuC,GACvD,GAAI,CAAC,EACH,MAAU,MACR,mFACF,EAEF,OAAO,CACT"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"context-D7eEItCY.mjs","names":[],"sources":["../src/internal/context.ts"],"sourcesContent":["/**\n * Shared per-instance state for ContentClient feature modules.\n *\n * The package's domain modules (`versions.ts`, `drafts.ts`,\n * `translation-status.ts`, `locale-status.ts`, `layout.ts`) are written\n * as free functions that take a `ContentContext` as their first\n * argument. ContentClient itself is a thin shell that builds the\n * context once and forwards every public method to the corresponding\n * free function.\n *\n * Why free functions instead of one class with everything inline:\n * - 1000+ LoC in one class is impossible to navigate and audit.\n * - Each module has clear inputs (the context + method args) and no\n * hidden dependencies on private state.\n * - Splits cleanly along the same boundaries as the per-entity\n * capability tables (drafts, versions, locale_status, ...) — every\n * module owns exactly one capability surface.\n */\n\nimport 'server-only'\n\nimport { type Logger, requireSiteFor } from '@murumets-ee/core'\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { getVersionableOptions } from '../behaviors/versionable.js'\nimport { getContentTables } from '../schema/registry.js'\n\n/**\n * Per-instance content context. Built once in `createContentContext()`\n * and threaded through every domain module's free functions.\n */\nexport interface ContentContext<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n admin: AdminClient<AllFields>\n db: PostgresJsDatabase\n versionableOptions: ReturnType<typeof getVersionableOptions>\n logger?: Logger | undefined\n}\n\n/**\n * Build a content context from an AdminClient + entity. The DB handle\n * is read off the AdminClient (it owns the read-write connection).\n */\nexport function createContentContext<AllFields extends Record<string, FieldConfig>>(\n config: {\n admin: AdminClient<AllFields>\n entity: Entity<AllFields>\n logger?: Logger\n },\n // Lazy import to avoid a static cycle: behaviors/versionable.ts is loaded\n // by client.ts which would otherwise close the loop. Phase-2 validated\n // the static import graph; this is a private helper bundled into the\n // same chunk so the dynamic-vs-static rule doesn't apply.\n versionableOptions: ReturnType<typeof getVersionableOptions>,\n): ContentContext<AllFields> {\n return {\n entity: config.entity,\n admin: config.admin,\n db: config.admin.getDb(),\n versionableOptions,\n logger: config.logger,\n }\n}\n\n// -------------------------------------------------------------------\n// Per-capability TableClient lookups. For a site-bound entity the client\n// is CONFINED to the request's site (multisite D003) — `requireSiteFor`\n// throws when there is none, so a companion read can never widen to every\n// site. Async for that reason: the single-site answer may need a lookup.\n// Each returns null if the entity\n// hasn't registered the corresponding table (i.e. doesn't have the\n// behavior). ContentClient public methods translate that null into\n// either a thrown EntityCapabilityError (writes) or a falsy return\n// (reads).\n// -------------------------------------------------------------------\n\nexport async function getDraftsClient(ctx: ContentContext) {\n const { drafts } = getContentTables(ctx.entity.name)\n return drafts ? drafts.makeClient(ctx.db, await requireSiteFor(ctx.entity)) : null\n}\n\nexport async function getVersionsClient(ctx: ContentContext) {\n const { versions } = getContentTables(ctx.entity.name)\n return versions ? versions.makeClient(ctx.db, await requireSiteFor(ctx.entity)) : null\n}\n\nexport async function getLocaleStatusClient(ctx: ContentContext) {\n const { localeStatus } = getContentTables(ctx.entity.name)\n return localeStatus ? localeStatus.makeClient(ctx.db, await requireSiteFor(ctx.entity)) : null\n}\n"],"mappings":"kRA8CA,SAAgB,EACd,EASA,EAC2B,CAC3B,MAAO,CACL,OAAQ,EAAO,OACf,MAAO,EAAO,MACd,GAAI,EAAO,MAAM,MAAM,EACvB,qBACA,OAAQ,EAAO,MACjB,CACF,CAcA,eAAsB,EAAgB,EAAqB,CACzD,GAAM,CAAE,UAAW,EAAiB,EAAI,OAAO,IAAI,EACnD,OAAO,EAAS,EAAO,WAAW,EAAI,GAAI,MAAM,EAAe,EAAI,MAAM,CAAC,EAAI,IAChF,CAEA,eAAsB,EAAkB,EAAqB,CAC3D,GAAM,CAAE,YAAa,EAAiB,EAAI,OAAO,IAAI,EACrD,OAAO,EAAW,EAAS,WAAW,EAAI,GAAI,MAAM,EAAe,EAAI,MAAM,CAAC,EAAI,IACpF,CAEA,eAAsB,EAAsB,EAAqB,CAC/D,GAAM,CAAE,gBAAiB,EAAiB,EAAI,OAAO,IAAI,EACzD,OAAO,EAAe,EAAa,WAAW,EAAI,GAAI,MAAM,EAAe,EAAI,MAAM,CAAC,EAAI,IAC5F"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"detail-template-names-CD1Y9n1U.mjs","names":[],"sources":["../src/detail-template-names.ts"],"sourcesContent":["/**\n * The detail-template entity's name, and the list-time predicate its seeder\n * uses — in a module that imports NOTHING, for the reason\n * `./chrome-entity-names.ts` gives: `available` runs on the seed page's\n * synchronous shell and inside `lumi seed --list`, so it cannot sit behind an\n * `await import`, and importing it from a module that reaches\n * `@murumets-ee/core/clients` would hoist that into `dist/plugin.mjs`.\n */\n\n/** The one detail-template entity. Rows differ by `target`, not by entity name. */\nexport const DETAIL_TEMPLATE_ENTITY_NAME = 'detail_template'\n\n/** The column holding the entity a template draws. Also its unique key. */\nexport const DETAIL_TEMPLATE_TARGET_FIELD = 'target'\n\n/**\n * Does this app have the detail-template entity for the seeder to create rows\n * in? `app` is `unknown` — see `hasChromeEntities`.\n */\nexport function hasDetailTemplateEntity(app: unknown): boolean {\n const entities = (app as { entities?: Map<string, unknown> } | null | undefined)?.entities\n if (!entities || typeof entities.has !== 'function') return false\n return entities.has(DETAIL_TEMPLATE_ENTITY_NAME)\n}\n"],"mappings":"AAUA,MAAa,EAA8B,kBAG9B,EAA+B,SAM5C,SAAgB,EAAwB,EAAuB,CAC7D,IAAM,EAAY,GAAgE,SAElF,MADI,CAAC,GAAY,OAAO,EAAS,KAAQ,WAAmB,GACrD,EAAS,IAAI,CAA2B,CACjD"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"detail-template-provider.d.mts","names":[],"sources":["../src/detail-template-provider.ts"],"mappings":";;;AAmFA;AAAA,UAjBiB,sBAAA;;WAEN,EAAA;EAAA,SACA,MAAM;AAAA;AAAA,UAGA,sBAAA;EAcQ;EAAA,SAZd,MAAA;EACT,YAAA,CAAa,IAAA;IAAQ,MAAA;IAAgB,MAAA;EAAA,IAAmB,OAAA,CAAQ,sBAAA;EAChE,SAAA,CAAU,IAAA;IACR,MAAA;IACA,GAAA,EAAK,QAAA,CAAS,MAAA;IACd,MAAA;EAAA,IACE,OAAA,CAAQ,MAAA;AAAA;AAAA,iBAGE,4BAAA,CACd,MAAA,QAAc,OAAA,CAAQ,UAAA,IAAc,UAAA,EACpC,OAAA;EAAW,aAAA;AAAA,IACV,sBAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"detail-template-provider.mjs","names":[],"sources":["../src/detail-template-provider.ts"],"sourcesContent":["/**\n * The detail-template provider `content-api` is handed (block-library D009).\n *\n * `content-api` is a leaf: it knows an entity is drawn by a template\n * (`frontend.drawnBy`), and nothing about where a template is stored or which\n * of a row's columns a site lets a visitor see. Both answers live here, and are\n * injected as {@link createDetailTemplateProvider}.\n *\n * ## 🔴 What a visitor can reach through this\n *\n * - **`readTemplate`** answers for a row's ENTITY NAME, which the handler takes\n * from a whitelisted entity the caller holds `view` on — never from a request\n * parameter and never from a stored value. It reads the PUBLISHED template\n * for that entity and locale: the read runs under a reach context that grants\n * `view` and withholds `view_unpublished`, so `QueryClient`'s publish filter\n * and trashed-row exclusion still apply. A draft template is `null`, and the\n * page falls back to a plain one.\n *\n * It runs under reach rather than under the caller's own grants on purpose. A\n * template is not a document a visitor asks for; it is how a row they may\n * already read is drawn, the same derivation a menu or a path resolution is.\n * Requiring `detail_template:view` on `public` would be a second grant an\n * operator has to remember, whose absence looks exactly like \"no template\n * published\" — the two-grant trap `chrome-provider.ts` records.\n *\n * - **`publicRow`** is the ONLY thing of the row that crosses, besides its id.\n * It projects `title` and `description` through the site's detail declaration\n * (`blocksPlugin({ details })`), whose columns were validated at boot as\n * declared, non-`internal`, non-`adminOnly`. An entity with no declaration\n * yields `{}`. No stored value names a column.\n *\n * ## Fan-out\n *\n * One query per `readTemplate` call, and the handler calls it once per by-id\n * response. `publicRow` does no I/O (it awaits only the app handle).\n */\n\nimport {\n type DocumentDetailPart,\n effectiveDetailSpecs,\n resolveDocumentDetail,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { createSystemReachResolver } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { SecurityContext } from '@murumets-ee/entity'\nimport { getBlockTreeFieldName, isTemplateDrawn } from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { eq, getTableColumns } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport {\n DETAIL_TEMPLATE_ENTITY_NAME,\n DETAIL_TEMPLATE_TARGET_FIELD,\n} from './detail-template-names.js'\n\n/**\n * Reach, never visibility of unpublished rows — see the file header. Named so\n * its reads are distinguishable from the path and menu resolvers' in audit\n * output.\n */\nconst SYSTEM_CONTEXT: () => SecurityContext = createSystemReachResolver('detail-template-resolver')\n\n/** The parts of a detail that are the row's public name and summary. */\nconst PUBLIC_ROW_PARTS: ReadonlySet<DocumentDetailPart> = new Set(['title', 'text'])\n\n/** A published template, as stored — its blocks are resolved by the caller. */\nexport interface DetailTemplateDocument {\n /** The template row's id, for the response's cache tags. */\n readonly id: string\n readonly blocks: unknown[]\n}\n\nexport interface DetailTemplateProvider {\n /** The template entity's name, for the response's cache tags. */\n readonly entity: string\n readTemplate(opts: { entity: string; locale: string }): Promise<DetailTemplateDocument | null>\n publicRow(opts: {\n entity: string\n row: Readonly<Record<string, unknown>>\n locale: string\n }): Promise<Record<string, unknown>>\n}\n\nexport function createDetailTemplateProvider(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: { defaultLocale: string },\n): DetailTemplateProvider {\n return {\n entity: DETAIL_TEMPLATE_ENTITY_NAME,\n\n readTemplate: async ({ entity: targetName, locale }) => {\n const app = await getApp()\n // Asked again here rather than trusted from the caller: this is the gate\n // that keeps the provider from being a way to read a template for an\n // entity code never marked as drawn by one.\n const target = app.entities.get(targetName)\n if (!target || !isTemplateDrawn(target)) return null\n\n const templateEntity = app.entities.get(DETAIL_TEMPLATE_ENTITY_NAME)\n if (!templateEntity) return null\n const blockTreeField = getBlockTreeFieldName(templateEntity)\n const table = schemaRegistry.get(DETAIL_TEMPLATE_ENTITY_NAME)\n // `?.` — see `chrome-provider.ts`: a registry entry that is not a real\n // table must degrade to \"no template\", not throw on every detail page.\n const columns = table\n ? (getTableColumns(table) as Record<string, PgColumn> | undefined)\n : undefined\n const targetColumn = columns?.[DETAIL_TEMPLATE_TARGET_FIELD]\n if (!blockTreeField || !targetColumn) return null\n\n const client = new QueryClient({\n entity: templateEntity,\n db: app.db.readOnly,\n logger: app.logger.child({ resolve: 'detail-template' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // `eq(target)` on the unique column: at most one live row can match.\n const rows = await client.findMany({\n limit: 1,\n locale,\n defaultLocale: options.defaultLocale,\n where: eq(targetColumn, targetName),\n })\n const row = rows[0] as Record<string, unknown> | undefined\n if (!row || typeof row.id !== 'string') return null\n const blocks = row[blockTreeField]\n // A published template with nothing in it is \"no template\": the plain\n // fallback is a better page than an empty one.\n if (!Array.isArray(blocks) || blocks.length === 0) return null\n return { id: row.id, blocks }\n },\n\n publicRow: async ({ entity, row, locale }) => {\n const app = await getApp()\n const spec = effectiveDetailSpecs(app).specs.get(entity)\n if (spec === undefined) return {}\n const detail = resolveDocumentDetail(spec, row, locale, PUBLIC_ROW_PARTS)\n return {\n ...(detail?.title !== undefined && { title: detail.title }),\n ...(detail?.text !== undefined && { description: detail.text }),\n }\n },\n }\n}\n"],"mappings":"scA4DA,MAAM,EAAwC,EAA0B,0BAA0B,EAG5F,EAAoD,IAAI,IAAI,CAAC,QAAS,MAAM,CAAC,EAoBnF,SAAgB,EACd,EACA,EACwB,CACxB,MAAO,CACL,OAAQ,EAER,aAAc,MAAO,CAAE,OAAQ,EAAY,YAAa,CACtD,IAAM,EAAM,MAAM,EAAO,EAInB,EAAS,EAAI,SAAS,IAAI,CAAU,EAC1C,GAAI,CAAC,GAAU,CAAC,EAAgB,CAAM,EAAG,OAAO,KAEhD,IAAM,EAAiB,EAAI,SAAS,IAAI,CAA2B,EACnE,GAAI,CAAC,EAAgB,OAAO,KAC5B,IAAM,EAAiB,EAAsB,CAAc,EACrD,EAAQ,EAAe,IAAI,CAA2B,EAMtD,GAHU,EACX,EAAgB,CAAK,EACtB,IAAA,GAAA,GAC2B,GAC/B,GAAI,CAAC,GAAkB,CAAC,EAAc,OAAO,KAe7C,IAAM,GAAM,MANO,IAPA,EAAY,CAC7B,OAAQ,EACR,GAAI,EAAI,GAAG,SACX,OAAQ,EAAI,OAAO,MAAM,CAAE,QAAS,iBAAkB,CAAC,EACvD,gBAAiB,CACnB,CAEwB,CAAC,CAAC,SAAS,CACjC,MAAO,EACP,SACA,cAAe,EAAQ,cACvB,MAAO,EAAG,EAAc,CAAU,CACpC,CAAC,EAAA,CACgB,GACjB,GAAI,CAAC,GAAO,OAAO,EAAI,IAAO,SAAU,OAAO,KAC/C,IAAM,EAAS,EAAI,GAInB,MADI,CAAC,MAAM,QAAQ,CAAM,GAAK,EAAO,SAAW,EAAU,KACnD,CAAE,GAAI,EAAI,GAAI,QAAO,CAC9B,EAEA,UAAW,MAAO,CAAE,SAAQ,MAAK,YAAa,CAE5C,IAAM,EAAO,EAAqB,MADhB,EAAO,CACY,CAAC,CAAC,MAAM,IAAI,CAAM,EACvD,GAAI,IAAS,IAAA,GAAW,MAAO,CAAC,EAChC,IAAM,EAAS,EAAsB,EAAM,EAAK,EAAQ,CAAgB,EACxE,MAAO,CACL,GAAI,GAAQ,QAAU,IAAA,IAAa,CAAE,MAAO,EAAO,KAAM,EACzD,GAAI,GAAQ,OAAS,IAAA,IAAa,CAAE,YAAa,EAAO,IAAK,CAC/D,CACF,CACF,CACF"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"document-site-B8tmEqLw.mjs","names":[],"sources":["../src/internal/document-site.ts"],"sourcesContent":["/**\n * The site a document-type-keyed companion access acts on (plan `multisite`\n * D003) — for the stores the block editor calls with a bare `documentType`\n * string rather than an entity object (`document-draft-store.ts`,\n * `document-version-store.ts`).\n *\n * `undefined` for a shared entity, or a type this app does not know (its\n * content tables are not registered either, so nothing is read). Throws\n * `SiteRequiredError` for a bound entity with no site in the request.\n */\n\nimport { getAppOrUndefined, getContext, requireSiteFor } from '@murumets-ee/core'\n\nexport async function siteForDocumentType(documentType: string): Promise<string | undefined> {\n // No booted app (a unit harness): there is no entity to consult, and a\n // bound type's partitioned table then refuses to build an unconfined client\n // — so returning `undefined` here cannot widen anything.\n const app = getContext()?.app ?? getAppOrUndefined()\n if (!app) return undefined\n const entity = app.entities.get(documentType)\n if (!entity) return undefined\n return requireSiteFor(entity, app)\n}\n"],"mappings":"mLAaA,eAAsB,EAAoB,EAAmD,CAI3F,IAAM,EAAM,EAAW,CAAC,EAAE,KAAO,EAAkB,EACnD,GAAI,CAAC,EAAK,OACV,IAAM,EAAS,EAAI,SAAS,IAAI,CAAY,EACvC,KACL,OAAO,EAAe,EAAQ,CAAG,CACnC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/entities/detail-template.ts"],"mappings":";;;;;UAqEiB,2BAAA;EA0FwD;;;;;;;;EAjFvE,GAAA,WAAc,IAAA,CAAK,MAAA;;EAEnB,QAAA;;EAEA,UAAA;;EAEA,SAAA;;EAEA,WAAA;;EAEA,WAAA;;EAEA,SAAA,YAAqB,QAAA,CAAS,MAAA;;EAE9B,KAAA;IACE,cAAA,GAAiB,iBAAA;EAAA;AAAA;;cAsBR,gCAAA,SAAyC,cAAc;EAAd,WAAA,CAAA;AAAA;;iBAwBtC,qBAAA,CACd,QAAA,WAAmB,IAAI,CAAC,MAAA;;;;;iBAmBV,oBAAA,CAAqB,OAAA,EAAS,2BAAA,GAA2B,MAAA;;sGAAA,iBAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/entities/detail-template.ts"],"sourcesContent":["/**\n * `detail_template` — the ONE stored block document per entity type that draws\n * every row of an entity the site does not author (block-library D009).\n *\n * A synced room type or a synced legal document has no block-tree field and\n * cannot have one: its rows are written by a machine, so there is nothing an\n * editor could save a layout into. Its detail page is therefore a document of\n * THIS entity, keyed by the entity it draws. An editor opens \"Room detail\" on\n * the canvas and edits it like a page — adds, moves and removes blocks,\n * translates its fixed copy per locale, publishes it — and the site draws that\n * one published tree for every room, with that room's values in the bound\n * blocks.\n *\n * ## The same shape as `site_chrome`, for the same reasons\n *\n * One entity, one row per key, the key's `unique: true` as the whole\n * cardinality rule (a partial index, `WHERE \"deletedAt\" IS NULL`, because a\n * publishable entity is trashable). `layout` is `translatable`, so each locale\n * is an independent tree. `withOpLog()` comes from the app, because it ships\n * from `@murumets-ee/blocks/server` and this package must not depend on blocks:\n *\n * ```ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { detailTemplateEntity } from '@murumets-ee/content/entities'\n *\n * export const DetailTemplate = detailTemplateEntity({\n * for: [RoomType, LegalDocument],\n * environment: 'page',\n * behaviors: [withOpLog({ retentionDays: 7 })],\n * })\n * ```\n *\n * Rows are created by the `content:detail-templates` bootstrap seeder (one\n * DRAFT per target, on every `lumi migrate`), so `admin.disableCreate` is on.\n *\n * ## 🔴 `target` is a key, never a choice\n *\n * `target` is a `select` whose options are the names of the entities passed in\n * `for` — each of which must itself declare `frontend: { drawnBy: 'template' }`\n * or this throws at boot. So a stored value can only ever be an entity that\n * CODE offered, and the public read never follows it: the site resolves a URL\n * to a row, and asks for the template WHERE `target` = that row's entity. No\n * request reads a template and then goes looking for the entity it names.\n *\n * `layout`'s `rowsFrom: 'target'` is what tells the canvas to draw the bound\n * blocks with a sample row of that entity, read-only.\n *\n * ## Deleting is refused; unpublishing is the off switch\n *\n * While an entity is drawn by a template its rows are routable, and a deleted\n * template would leave them with nothing an editor can open again (the row is\n * bootstrapped, and `disableCreate` hides the only other way to make one). So\n * delete and trash are refused outright. To take a detail page back to the\n * plain fallback, UNPUBLISH the template: the site then draws the row's title\n * on a plain page, and the draft is still there to publish again.\n */\n\nimport type { Behavior, Entity, EntityAdminConfig } from '@murumets-ee/entity'\nimport {\n auditable,\n defineEntity,\n ForbiddenError,\n field,\n isTemplateDrawn,\n publishable,\n} from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\nimport { DETAIL_TEMPLATE_ENTITY_NAME } from '../detail-template-names.js'\n\nexport interface DetailTemplateEntityOptions {\n /**\n * The entities drawn by a stored template. Each must declare\n * `frontend: { path, drawnBy: 'template' }`; one that does not throws here,\n * at boot, rather than producing a template nothing ever draws.\n *\n * May be empty — a project with no such entity yet keeps the registration and\n * adds a name when it gets one. The entity is then hidden from the sidebar.\n */\n for: readonly Pick<Entity, 'name' | 'frontend'>[]\n /** Role that may read templates in the admin. Default `'editor'`. */\n viewRole?: string\n /** Role that may edit and publish templates. Default `'editor'`. */\n updateRole?: string\n /** Role named for create/delete (both are otherwise closed). Default `'admin'`. */\n adminRole?: string\n /** Snapshots kept per template. Default 30. */\n maxVersions?: number\n /** The block environment the canvas opens (e.g. `'page'`). */\n environment?: string\n /** App-supplied behaviors — `withOpLog()` at least. Must contribute no fields. */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /** Field naming overrides, merged per field over the English defaults. */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description: 'Shown in the admin list and as the editor title. Visitors never see it.',\n },\n}\n\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/** Thrown when someone tries to delete or trash a detail template. */\nexport class DetailTemplateDeleteRefusedError extends ForbiddenError {\n constructor() {\n super(\n 'A detail template is not deleted: the rows it draws are still on the site. ' +\n 'Unpublish it instead — those pages then show a plain fallback, and the draft stays editable.',\n )\n this.name = 'DetailTemplateDeleteRefusedError'\n }\n}\n\n/**\n * Refuse every delete path. `beforeDelete` runs on a single delete, a purge, a\n * trash, `deleteMany` and a cascade, so this one hook covers all of them.\n */\nconst undeletable: Behavior<Record<never, never>, 'content:detail-template-undeletable'> = {\n name: 'content:detail-template-undeletable',\n hooks: {\n beforeDelete: async () => {\n throw new DetailTemplateDeleteRefusedError()\n },\n },\n}\n\n/** The names a template may target — validated, de-duplicated, in the given order. */\nexport function detailTemplateTargets(\n entities: readonly Pick<Entity, 'name' | 'frontend'>[],\n): string[] {\n const names: string[] = []\n for (const entity of entities) {\n if (!isTemplateDrawn(entity)) {\n throw new Error(\n `detailTemplateEntity: '${entity.name}' is listed in \\`for\\` but does not declare ` +\n \"`frontend: { path, drawnBy: 'template' }`. A template for it would never be drawn.\",\n )\n }\n if (!names.includes(entity.name)) names.push(entity.name)\n }\n return names\n}\n\n/**\n * Build the `detail_template` entity. The return type is INFERRED, for the\n * reason `siteChromeEntity` gives: annotating it widens the name to `string`.\n */\nexport function detailTemplateEntity(options: DetailTemplateEntityOptions) {\n const viewRole = options.viewRole ?? 'editor'\n const updateRole = options.updateRole ?? 'editor'\n const adminRole = options.adminRole ?? 'admin'\n const targets = detailTemplateTargets(options.for)\n\n return defineEntity({\n name: DETAIL_TEMPLATE_ENTITY_NAME,\n // Per site, like chrome: each site has its own detail pages.\n site: 'bound',\n kind: 'collection',\n fields: {\n // THE key. Not translatable — which entity a template draws is a property\n // of the document, not of a translation. `unique: true` is the whole\n // one-template-per-entity-type rule, enforced by the database for every\n // writer.\n target: field.select({\n options: targets,\n required: true,\n unique: true,\n }),\n label: field.text({ required: true }),\n layout: field.blockTree({\n translatable: true,\n rowsFrom: 'target',\n ...(options.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: options.maxVersions ?? 30 }),\n undeletable,\n ...(options.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'layout-template',\n description:\n 'One page layout per kind of synced record, drawn for every record of that kind.',\n // `target` is deliberately absent, as `placement` is on chrome: pointing\n // a room template at legal documents by editing a dropdown would collide\n // with the row already holding that target.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n disableCreate: true,\n // Nothing to list until a project has a template-drawn entity.\n ...(targets.length === 0 && { hidden: true }),\n },\n })\n}\n\n/**\n * COMPILE-TIME guard that the name stays a literal — see `site-chrome.ts`'s\n * twin for why it lives in `src/`.\n */\ntype DetailTemplateName = ReturnType<typeof detailTemplateEntity>['name']\nconst _nameIsLiteral: string extends DetailTemplateName ? never : true = true\nvoid _nameIsLiteral\n"],"mappings":"gSAiGA,MAAM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YAAa,yEACf,CACF,EAEA,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,CAGA,IAAa,EAAb,cAAsD,CAAe,CACnE,aAAc,CACZ,MACE,yKAEF,EACA,KAAK,KAAO,kCACd,CACF,EAMA,MAAM,EAAqF,CACzF,KAAM,sCACN,MAAO,CACL,aAAc,SAAY,CACxB,MAAM,IAAI,CACZ,CACF,CACF,EAGA,SAAgB,EACd,EACU,CACV,IAAM,EAAkB,CAAC,EACzB,IAAK,IAAM,KAAU,EAAU,CAC7B,GAAI,CAAC,EAAgB,CAAM,EACzB,MAAU,MACR,0BAA0B,EAAO,KAAK,iIAExC,EAEG,EAAM,SAAS,EAAO,IAAI,GAAG,EAAM,KAAK,EAAO,IAAI,CAC1D,CACA,OAAO,CACT,CAMA,SAAgB,EAAqB,EAAsC,CACzE,IAAM,EAAW,EAAQ,UAAY,SAC/B,EAAa,EAAQ,YAAc,SACnC,EAAY,EAAQ,WAAa,QACjC,EAAU,EAAsB,EAAQ,GAAG,EAEjD,OAAO,EAAa,CAClB,KAAM,EAEN,KAAM,QACN,KAAM,aACN,OAAQ,CAKN,OAAQ,EAAM,OAAO,CACnB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EACD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EACpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,SAAU,SACV,GAAI,EAAQ,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC9E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,EAAQ,aAAe,EAAG,CAAC,EACpD,EACA,GAAI,EAAQ,WAAa,CAAC,CAC5B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EACN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,kBACN,YACE,kFAIF,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,EAAQ,OAAO,cAAc,EACjE,WAAY,QACZ,MAAO,OACP,cAAe,GAEf,GAAI,EAAQ,SAAW,GAAK,CAAE,OAAQ,EAAK,CAC7C,CACF,CAAC,CACH"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"enumerate-CYHJKQz6.mjs","names":[],"sources":["../src/paths/enumerate.ts"],"sourcesContent":["/**\n * Path enumeration for SSG / sitemaps (plan/routing PR06; phase\n * `05-resolution-and-enumeration-api.md` §2).\n *\n * `enumeratePaths` streams the PUBLISHED canonical `(path, entityType, id,\n * updatedAt)` rows for one locale. It powers `getStaticPaths` + the sitemap\n * helper, so it must NEVER leak an unpublished document (CLAUDE.md #1 — the\n * output feeds public build artifacts).\n *\n * **Security + availability.** Two properties matter here:\n * 1. *No unpublished leak.* `toolkit_paths` carries no publish-status column,\n * so each page's rows are publish-checked against the live entity via the\n * injected {@link EnumeratePathsDeps.filterPublished} — an unpublished (or\n * trashed) document is dropped from the stream.\n * 2. *No unbounded fan-out* (CLAUDE.md \"no amplification\"). Work is bounded by\n * PAGE SIZE, not by total document count: rows are keyset-paginated on the\n * immutable `id` PK (one index query per page), and the publish check is\n * BATCHED — ONE call per distinct entity-type bucket per page (a handful of\n * types at most; the routable set is config-time-fixed, never\n * attacker-influenced), never one call per row.\n *\n * **Leaf module.** Every cross-package dependency (the index reader, the publish\n * filter) is injected via `deps`; this module imports no `@murumets-ee/core` and\n * no `server-only` graph. Concrete deps are built from an app in\n * `./resolve-context.ts` (`buildEnumerateDeps`), keeping the resolution surface\n * off the `lumi` CLI command graph (F027 guardrail).\n *\n * Function-form-path entities need NO special handling — their rows were already\n * materialized into `toolkit_paths` at write time (F004 / phase 05 §2), so a\n * plain index read includes them.\n */\n\nimport { mapBounded } from './apply-path.js'\n\n// ---------------------------------------------------------------------------\n// Read surface (a `type` alias, per index-reader.ts's Deviation-1 rationale, so\n// the real `ToolkitPathsClient` stays assignable to it without any cast).\n// ---------------------------------------------------------------------------\n\n/** The projected `toolkit_paths` row the enumeration + batch lookup read. */\nexport type PathEnumRow = {\n id: string\n entityType: string\n entityId: string\n locale: string\n path: string\n updatedAt: Date\n}\n\n/**\n * The (operator-carrying) where shapes enumeration issues: keyset `id > cursor`,\n * `entityId IN (...)` for the batch lookup, plus plain-value equality filters.\n * A subset of the real client's `ColumnOperators` where-clause, so the real\n * `ToolkitPathsClient` is assignable to {@link PathEnumReader}.\n */\nexport type PathEnumWhere = {\n id?: { gt: string }\n entityType?: string\n entityId?: { in: string[] }\n locale?: string\n /** `{ in }` for a batched membership read (`resolvePublishedPathsAmong`). */\n path?: string | { in: string[] }\n isCanonical?: boolean\n}\n\n/**\n * The batched keyset read surface {@link enumeratePaths} / {@link getCanonicalPaths}\n * need. NARROWER than Task 1's point-lookup `PathIndexReader` (which only orders\n * by `updatedAt` and takes plain-value where): enumeration needs `id`-ordering,\n * a `{ gt }` cursor, and `{ in }` batching. Construct the real client directly\n * (`toolkitPathsTable.makeClient(db)`) — it satisfies this type.\n */\nexport type PathEnumReader = {\n findMany(options: {\n where?: PathEnumWhere\n orderBy?: Array<{ column: 'id'; dir?: 'asc' | 'desc' }>\n limit?: number\n }): Promise<PathEnumRow[]>\n}\n\n/**\n * Batch publish check: return the SUBSET of `entityIds` that is published in\n * `locale`. Called ONCE per entity-type group per page. The concrete\n * implementation (see `resolve-context.ts`) runs an elevated-context\n * `QueryClient.findMany({ where: inArray(getTable().id, ids), locale })` — so no\n * unpublished/trashed id comes back. Since D017 the publish filter is\n * CONDITIONAL (it holds for any principal without `view_unpublished`); what\n * keeps that true here is that the elevated context is a REACH context which\n * withholds that action, not that the filter is unconditional.\n */\nexport type PublishFilter = (\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n) => Promise<ReadonlySet<string>>\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** One enumerated path row (phase 05 §2 — exact shape). */\nexport interface PathEntry {\n path: string\n entityType: string\n id: string\n updatedAt: Date\n}\n\n/** Options for {@link enumeratePaths} (phase 05 §2 — exact shape). */\nexport interface EnumeratePathsOptions {\n /** Enumerate this locale's canonical rows only. */\n locale: string\n /** Optionally narrow to a single routable entity type. */\n entityType?: string\n /**\n * Resume the keyset AFTER this `toolkit_paths` row id (exclusive); omit to\n * start from the beginning. The value is the opaque `toolkit_paths` PK the\n * internal page cursor advances on — NOT the yielded `PathEntry.id`, which is\n * the DOCUMENT (entity) id. A cross-request paginating caller (e.g. the\n * content-api `_paths` endpoint) resolves the last consumed entry's row PK and\n * passes it here to continue exactly where the prior page stopped, with no row\n * skipped or repeated. Fully backward-compatible: existing callers omit it and\n * traverse from the start unchanged.\n */\n afterId?: string\n}\n\n/** Injected dependencies for {@link enumeratePaths}. */\nexport interface EnumeratePathsDeps {\n pathsClient: PathEnumReader\n filterPublished: PublishFilter\n}\n\n/** Internal tuning seam (page size / batch concurrency). Real callers omit it. */\nexport interface EnumeratePathsTuning {\n /** Rows per index page. Defaults to {@link ENUMERATE_PAGE_SIZE}. Tests force a small value. */\n pageSize?: number\n /** Peak concurrent publish-check groups. Defaults to {@link PUBLISH_CHECK_CONCURRENCY}. */\n publishConcurrency?: number\n}\n\n/**\n * Index page size — mirrors the rebuild job's `REBUILD_PAGE_SIZE`. Must stay\n * ≤ the `defineTable` `MAX_LIMIT` (1000), which THROWS rather than clamps.\n */\nconst ENUMERATE_PAGE_SIZE = 200\n\n/**\n * Peak concurrent publish-check groups per page. A page holds a handful of\n * distinct entity types at most, so this bound is rarely reached — it exists so\n * the fan-out is EXPLICITLY capped (CLAUDE.md), never `Promise.all` over an\n * unbounded set.\n */\nconst PUBLISH_CHECK_CONCURRENCY = 5\n\n/**\n * Stream the published canonical paths for `opts.locale` (optionally narrowed to\n * `opts.entityType`), keyset-paginated with a per-page batched publish filter.\n * See the module header for the security + fan-out guarantees.\n */\nexport async function* enumeratePaths(\n opts: EnumeratePathsOptions,\n deps: EnumeratePathsDeps,\n tuning: EnumeratePathsTuning = {},\n): AsyncGenerator<PathEntry> {\n const pageSize = tuning.pageSize ?? ENUMERATE_PAGE_SIZE\n const concurrency = tuning.publishConcurrency ?? PUBLISH_CHECK_CONCURRENCY\n const { locale, entityType } = opts\n\n // Seed from `opts.afterId` so a caller can resume mid-stream; undefined starts\n // at the beginning (every existing caller's behavior, unchanged).\n let cursorId: string | undefined = opts.afterId\n for (;;) {\n // Keyset cursor on the immutable `id` PK (the rebuild job's precedent):\n // visits every canonical row exactly once even under concurrent mutation,\n // which OFFSET paging cannot guarantee.\n const where: PathEnumWhere = { locale, isCanonical: true }\n if (entityType !== undefined) where.entityType = entityType\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.pathsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Group this page's rows by entity type (bounded: the routable-type set is\n // small + config-fixed), then ONE publish check per group.\n const byType = new Map<string, PathEnumRow[]>()\n for (const row of page) {\n const bucket = byType.get(row.entityType)\n if (bucket) bucket.push(row)\n else byType.set(row.entityType, [row])\n }\n\n const publishedByType = new Map<string, ReadonlySet<string>>()\n await mapBounded([...byType.entries()], concurrency, async ([type, rows]) => {\n const published = await deps.filterPublished(\n type,\n rows.map((r) => r.entityId),\n locale,\n )\n publishedByType.set(type, published)\n })\n\n // Yield only the page's PUBLISHED rows, preserving the id-ascending order.\n for (const row of page) {\n if (publishedByType.get(row.entityType)?.has(row.entityId)) {\n yield {\n path: row.path,\n entityType: row.entityType,\n id: row.entityId,\n updatedAt: row.updatedAt,\n }\n }\n }\n\n // Advance the cursor to the last (max-id) row of this raw page. `page` is\n // non-empty here (length-0 broke above). Publish-filtering is a POST-filter,\n // so the cursor always steps over every index row regardless of publish state.\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n// ---------------------------------------------------------------------------\n// getCanonicalPaths — batched forward lookup (search url re-point), NO publish filter\n// ---------------------------------------------------------------------------\n\n/** Deps for {@link getCanonicalPaths}. */\nexport interface CanonicalPathsBatchDeps {\n pathsClient: PathEnumReader\n}\n\n/**\n * Max ids per batched index query — well under `defineTable`'s `MAX_LIMIT`\n * (1000, which throws rather than clamps). Search result pages are far smaller\n * than this, so this is one query in practice; larger id sets chunk safely.\n */\nconst CANONICAL_BATCH_SIZE = 500\n\n/**\n * The current canonical paths for many `(entityType, entityId)` in ONE locale,\n * as an `entityId → path` map — the batched sibling of `getCanonicalPath`.\n *\n * **No publish filter — deliberate**, matching `getCanonicalPath` (and the\n * search url builder it re-points): a search result's url is display-only and\n * the caller already gates row visibility. One index query per {@link\n * CANONICAL_BATCH_SIZE}-sized chunk (one query total for a normal search page),\n * so it never fans out per row.\n */\nexport async function getCanonicalPaths(\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n deps: CanonicalPathsBatchDeps,\n): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n for (let i = 0; i < entityIds.length; i += CANONICAL_BATCH_SIZE) {\n const chunk = entityIds.slice(i, i + CANONICAL_BATCH_SIZE)\n const rows = await deps.pathsClient.findMany({\n where: { entityType, locale, isCanonical: true, entityId: { in: [...chunk] } },\n limit: chunk.length,\n })\n for (const row of rows) result.set(row.entityId, row.path)\n }\n return result\n}\n"],"mappings":"sJAgKA,eAAuB,EACrB,EACA,EACA,EAA+B,CAAC,EACL,CAC3B,IAAM,EAAW,EAAO,UAAY,IAC9B,EAAc,EAAO,oBAAsB,EAC3C,CAAE,SAAQ,cAAe,EAI3B,EAA+B,EAAK,QACxC,OAAS,CAIP,IAAM,EAAuB,CAAE,SAAQ,YAAa,EAAK,EACrD,IAAe,IAAA,KAAW,EAAM,WAAa,GAC7C,IAAa,IAAA,KAAW,EAAM,GAAK,CAAE,GAAI,CAAS,GAEtD,IAAM,EAAO,MAAM,EAAK,YAAY,SAAS,CAC3C,QACA,QAAS,CAAC,CAAE,OAAQ,KAAM,IAAK,KAAM,CAAC,EACtC,MAAO,CACT,CAAC,EACD,GAAI,EAAK,SAAW,EAAG,MAIvB,IAAM,EAAS,IAAI,IACnB,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAS,EAAO,IAAI,EAAI,UAAU,EACpC,EAAQ,EAAO,KAAK,CAAG,EACtB,EAAO,IAAI,EAAI,WAAY,CAAC,CAAG,CAAC,CACvC,CAEA,IAAM,EAAkB,IAAI,IAC5B,MAAM,EAAW,CAAC,GAAG,EAAO,QAAQ,CAAC,EAAG,EAAa,MAAO,CAAC,EAAM,KAAU,CAC3E,IAAM,EAAY,MAAM,EAAK,gBAC3B,EACA,EAAK,IAAK,GAAM,EAAE,QAAQ,EAC1B,CACF,EACA,EAAgB,IAAI,EAAM,CAAS,CACrC,CAAC,EAGD,IAAK,IAAM,KAAO,EACZ,EAAgB,IAAI,EAAI,UAAU,CAAC,EAAE,IAAI,EAAI,QAAQ,IACvD,KAAM,CACJ,KAAM,EAAI,KACV,WAAY,EAAI,WAChB,GAAI,EAAI,SACR,UAAW,EAAI,SACjB,GAOJ,IAAM,EAAU,EAAK,EAAK,OAAS,GAGnC,GAFI,CAAC,IACL,EAAW,EAAQ,GACf,EAAK,OAAS,GAAU,KAC9B,CACF,CA4BA,eAAsB,EACpB,EACA,EACA,EACA,EAC8B,CAC9B,IAAM,EAAS,IAAI,IACnB,IAAK,IAAI,EAAI,EAAG,EAAI,EAAU,OAAQ,GAAK,IAAsB,CAC/D,IAAM,EAAQ,EAAU,MAAM,EAAG,EAAI,GAAoB,EACnD,EAAO,MAAM,EAAK,YAAY,SAAS,CAC3C,MAAO,CAAE,aAAY,SAAQ,YAAa,GAAM,SAAU,CAAE,GAAI,CAAC,GAAG,CAAK,CAAE,CAAE,EAC7E,MAAO,EAAM,MACf,CAAC,EACD,IAAK,IAAM,KAAO,EAAM,EAAO,IAAI,EAAI,SAAU,EAAI,IAAI,CAC3D,CACA,OAAO,CACT"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"errors-NRNILhhm.mjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Domain errors for ContentClient + LockService.\n */\n\n/**\n * Thrown when a caller invokes an entity-capability-specific method on an\n * entity that doesn't have the corresponding behavior — e.g. `saveDraft`\n * on a non-publishable entity, `setLocaleStatus` on an entity without\n * publishable+translatable, `restoreVersion` on a non-versionable entity.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 400 Bad Request (caller's fault) rather than 500 (server bug).\n */\nexport class EntityCapabilityError extends Error {\n readonly entityName: string\n readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n constructor(\n entityName: string,\n capability: EntityCapabilityError['capability'],\n message?: string,\n ) {\n super(\n message ??\n `Entity \"${entityName}\" does not support ${capability}. ` +\n `Add the corresponding behavior to defineEntity({ behaviors: [...] }).`,\n )\n this.name = 'EntityCapabilityError'\n this.entityName = entityName\n this.capability = capability\n }\n}\n\n/**\n * Thrown by `publishDraft` when there is no pending draft for the requested\n * entity+locale — publishing is a promote-the-draft operation, so \"nothing\n * to promote\" is a caller-state condition, not a server fault.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 409 Conflict with a human-readable message instead of a generic 500\n * (which is what users saw pre-PR-23 — F031 #1).\n */\nexport class DraftNotFoundError extends Error {\n readonly entityId: string\n readonly locale: string\n\n constructor(entityId: string, locale: string) {\n // NB: the `_` default-locale sentinel is an internal storage key — never\n // show it to users (PR 23 user-smoke find).\n super(\n locale === '_'\n ? 'No pending draft to publish — this document is already up to date.'\n : `No pending draft to publish for locale \"${locale}\" — it is already up to date.`,\n )\n this.name = 'DraftNotFoundError'\n this.entityId = entityId\n this.locale = locale\n }\n}\n\n/**\n * Thrown by `restoreVersion` when the requested version snapshot does not\n * exist for the entity+locale — either the version number is out of range,\n * or the entity row itself is gone (a deleted row has no versions).\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 404 Not Found, matching the route-level 404 the publishable branch\n * already returns for the same condition. Before this it fell through to\n * the generic 500 branch on non-publishable entities — the same\n * \"caller-state condition audited as a server fault\" defect as\n * `EntityNotFoundError` (`plan/admin-api-hardening` F008).\n */\nexport class VersionNotFoundError extends Error {\n readonly entityId: string\n readonly version: number\n readonly locale: string\n\n constructor(entityId: string, version: number, locale: string) {\n super(`Version ${version} not found for entity ${entityId}`)\n this.name = 'VersionNotFoundError'\n this.entityId = entityId\n this.version = version\n this.locale = locale\n }\n}\n\n/**\n * Thrown when a locale string passed at runtime is not configured in the\n * content plugin. The plugin validates `defaultLocale` at init time, but\n * the per-call `locale` parameter on every read/write is not naturally\n * checked — `validateLocale()` is the boundary check.\n *\n * Allows the `'_'` sentinel (used internally for default-locale rows in\n * versions/drafts/locale_status tables) so callers can pass it through.\n */\nexport class InvalidLocaleError extends Error {\n readonly locale: string\n readonly allowed: readonly string[]\n\n constructor(locale: string, allowed: readonly string[]) {\n super(\n `Invalid locale \"${locale}\". Configured locales: [${allowed.join(', ')}]. ` +\n `(The \"_\" sentinel is also accepted for default-locale storage.)`,\n )\n this.name = 'InvalidLocaleError'\n this.locale = locale\n this.allowed = allowed\n }\n}\n"],"mappings":"AAaA,IAAa,EAAb,cAA2C,KAAM,CAC/C,WACA,WAEA,YACE,EACA,EACA,EACA,CACA,MACE,GACE,WAAW,EAAW,qBAAqB,EAAW,wEAE1D,EACA,KAAK,KAAO,wBACZ,KAAK,WAAa,EAClB,KAAK,WAAa,CACpB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,SACA,OAEA,YAAY,EAAkB,EAAgB,CAG5C,MACE,IAAW,IACP,qEACA,2CAA2C,EAAO,8BACxD,EACA,KAAK,KAAO,qBACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,EAca,EAAb,cAA0C,KAAM,CAC9C,SACA,QACA,OAEA,YAAY,EAAkB,EAAiB,EAAgB,CAC7D,MAAM,WAAW,EAAQ,wBAAwB,GAAU,EAC3D,KAAK,KAAO,uBACZ,KAAK,SAAW,EAChB,KAAK,QAAU,EACf,KAAK,OAAS,CAChB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,OACA,QAEA,YAAY,EAAgB,EAA4B,CACtD,MACE,mBAAmB,EAAO,0BAA0B,EAAQ,KAAK,IAAI,EAAE,mEAEzE,EACA,KAAK,KAAO,qBACZ,KAAK,OAAS,EACd,KAAK,QAAU,CACjB,CACF"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index-cT8Qk4gu.d.mts","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/diff/types.ts","../src/errors.ts","../src/locale-registry.ts","../src/message-registry.ts"],"mappings":"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"}
|
package/dist/index.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/message-registry.ts"],"sourcesContent":["/**\n * `withOverrides()` — opt-in behavior wiring a document entity into the\n * page-scoped override resolver shipped by phase 05.\n *\n * Adds a single internal field `_overrides: Record<string, OverrideDirective>`\n * to the host entity, defaulted to an empty object. The composition step\n * (`composeChromeRegion` in `@murumets-ee/blocks`) reads this field and calls\n * `applyOverrides()` on each chrome region AFTER its blocks have been\n * scope-tagged.\n *\n * Apps opt-in:\n *\n * @example\n * ```ts\n * defineEntity({\n * name: 'page',\n * fields: { ... },\n * behaviors: [\n * publishable(),\n * versionable({ maxPerDoc: 30 }),\n * workflowable(),\n * withOpLog({ retentionDays: 7 }),\n * withOverrides(), // ← enables page-level overrides on scoped blocks\n * ],\n * })\n * ```\n *\n * Why a behavior (not a plain field):\n * - Discoverability — `entitySupports(entity, 'overrides')` lets the boot\n * orphan-sweep find every overrides-eligible entity without hard-coding\n * a list.\n * - Validation — the behavior's `beforeUpdate` hook validates the shape\n * of the `_overrides` map (Zod-equivalent runtime check) so a malformed\n * PATCH body can't poison the field.\n * - Defensive defaults — `_overrides: {}` is auto-seeded on `beforeCreate`\n * so consumers always see a value, never `undefined`.\n *\n * `internal: true` on the field keeps it out of HTTP PATCH input — the\n * `doc:scope-override:set` op is the only path that can mutate it (writes\n * are routed through `AdminClient.updateInternal()` after permission\n * gating).\n *\n * See `plan/blocks/05-scoping-overrides.md` §5 + `progress/findings/F009`.\n */\n\nimport type { Behavior, JsonField } from '@murumets-ee/entity'\nimport { field } from '@murumets-ee/entity'\n\n/**\n * Override-directive shape. Mirrored from `@murumets-ee/blocks` to avoid\n * a circular dep (content → blocks → content); content stores the\n * persisted JSON, blocks owns the resolver. Keep the two shapes in sync.\n */\ntype OverrideDirective =\n | { kind: 'hide' }\n | {\n kind: 'patch'\n props?: Record<string, unknown>\n traits?: Record<string, Record<string, unknown>>\n }\n | { kind: 'replace'; block: Record<string, unknown> }\n\ntype OverridesMap = Record<string, OverrideDirective>\n\nexport type WithOverridesFields = {\n // `required: false` at the type level — the `beforeCreate` hook\n // seeds the field with `{}` when callers omit it, so making the\n // INPUT optional is more ergonomic than threading `_overrides: {}`\n // through every consumer's create-input.\n //\n // NOTE: the DB column is NULLABLE in the generated migration\n // (`_overrides jsonb`) — the JsonField `default` is a runtime\n // function the entity layer calls on create, NOT a SQL DEFAULT\n // clause the migration generator sees. Readers MUST treat NULL\n // as equivalent to `{}` (see `compose-chrome.ts`'s `asOverridesMap`\n // for the canonical pattern). A row inserted\n // via raw SQL or migration that bypasses the entity layer can\n // leave NULL; that's safe.\n _overrides: JsonField\n}\n\n/**\n * Allowed directive kinds — closed set. Unknown discriminators are rejected\n * at the behavior boundary so a malformed PATCH (or future plugin)\n * can't write opaque shapes that the resolver silently ignores.\n */\nconst ALLOWED_KINDS: ReadonlySet<string> = new Set(['hide', 'patch', 'replace'])\n\n/**\n * Maximum number of override entries per document. Bounded to keep the\n * page row's JSON column compact + the rendering hot path predictable.\n * Per phase 05 §9: a page overriding more than ~50 scoped blocks is a\n * smell — at that point the shared chrome is the wrong chrome for that\n * page, and the answer is a different `site_chrome` row or page-local blocks,\n * not more patching.\n */\nconst MAX_OVERRIDES_PER_DOC = 100\n\n/** Marker symbol so introspection can pick out the behavior reliably. */\nconst WITH_OVERRIDES_MARKER = Symbol.for('@murumets-ee/content:withOverrides')\n\ninterface WithOverridesMarker {\n [WITH_OVERRIDES_MARKER]: true\n}\n\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\n\n/**\n * Walk a plain-object value's OWN keys and reject any that match a\n * prototype-pollution vector. JSON.parse can create own properties\n * with these names; the dispatcher already filters at the route +\n * op-handler boundary, but the behavior is the third layer of\n * defense (any persistence path that bypasses the dispatcher — DB\n * seed, raw SQL migration, future internal API — lands here on the\n * next update).\n *\n * The depth cap of 3 covers every documented over-the-wire shape:\n *\n * - patch.props.__proto__ → depth 1\n * - patch.traits.spacing.__proto__ → depth 2\n * - replace.block.props.__proto__ → depth 2\n * - replace.block.traits.spacing.__proto__ → depth 3\n *\n * (Counting from the directive root at depth 0. The recursion CHECKS\n * keys at the entered depth, then recurses while `depth < 3`, so the\n * deepest check happens at depth=3.)\n *\n * Beyond that depth the structure is field VALUES which are caller-\n * payload (block field type / trait field type) — those are the\n * field-type validator's job, not the override-map gate.\n *\n * `path` is appended to the thrown error for triage.\n */\nfunction assertNoForbiddenKeys(value: unknown, path: string, depth = 0): void {\n if (value === null || value === undefined) return\n if (typeof value !== 'object' || Array.isArray(value)) return\n for (const key of Object.keys(value as Record<string, unknown>)) {\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' at ${path} is not allowed.`)\n }\n if (depth < 3) {\n assertNoForbiddenKeys((value as Record<string, unknown>)[key], `${path}.${key}`, depth + 1)\n }\n }\n}\n\n/**\n * Validate an override-map payload. Throws when malformed; returns the\n * (defensively-cloned, sanitised) value when ok. Called from the\n * `beforeUpdate` hook — `internal: true` blocks PATCH-side input but\n * the `doc:scope-override:set` op writes via `updateInternal` and so\n * still flows through here.\n *\n * Defense layered: outer scope-block-id keys + each directive's\n * recursive nested keys (patch.props / patch.traits / replace.block.props\n * / replace.block.traits) all walk through `assertNoForbiddenKeys`. The\n * dispatcher's own handler validates the same set; this is the\n * defense-in-depth replay at the persistence boundary.\n */\nfunction validateOverridesMap(value: unknown): OverridesMap {\n if (value === null || value === undefined) return {}\n if (typeof value !== 'object' || Array.isArray(value)) {\n throw new Error('withOverrides: _overrides must be a plain object map.')\n }\n const map = value as Record<string, unknown>\n const keys = Object.keys(map)\n if (keys.length > MAX_OVERRIDES_PER_DOC) {\n throw new Error(\n `withOverrides: _overrides has ${keys.length} entries; cap is ${MAX_OVERRIDES_PER_DOC}.`,\n )\n }\n const sanitised: OverridesMap = {}\n for (const key of keys) {\n // Skip prototype-pollution vectors at the OUTER (scopeBlockId)\n // key boundary first.\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' is not allowed.`)\n }\n const directive = map[key]\n if (\n directive === null ||\n directive === undefined ||\n typeof directive !== 'object' ||\n Array.isArray(directive)\n ) {\n throw new Error(`withOverrides: _overrides['${key}'] must be a directive object.`)\n }\n const kind = (directive as { kind?: unknown }).kind\n if (typeof kind !== 'string' || !ALLOWED_KINDS.has(kind)) {\n throw new Error(\n `withOverrides: _overrides['${key}'].kind must be one of [hide, patch, replace]; got ${String(\n kind,\n )}.`,\n )\n }\n // Kind-specific shape enforcement (CR bot major) — `kind` alone\n // isn't enough; a malformed `{ kind: 'replace' }` (no block) or\n // `{ kind: 'patch', props: 123 }` would otherwise pass the gate\n // and land invalid data in persistence. Mirror the dispatcher\n // handler's shape checks at this third defense layer.\n if (kind === 'patch') {\n const d = directive as { props?: unknown; traits?: unknown }\n assertPlainObjectOrUndefined(d.props, `_overrides['${key}'].props`)\n assertPlainObjectOrUndefined(d.traits, `_overrides['${key}'].traits`)\n }\n if (kind === 'replace') {\n const block = (directive as { block?: unknown }).block\n if (\n block === null ||\n block === undefined ||\n typeof block !== 'object' ||\n Array.isArray(block) ||\n typeof (block as { id?: unknown }).id !== 'string'\n ) {\n throw new Error(\n `withOverrides: _overrides['${key}'] replace payload must include an object 'block' with a string 'id'.`,\n )\n }\n }\n // Recurse into the directive payload — patch.props / patch.traits\n // / replace.block.props / replace.block.traits — and reject any\n // forbidden own-property keys at every level.\n assertNoForbiddenKeys(directive, `_overrides['${key}']`)\n sanitised[key] = directive as OverrideDirective\n }\n return sanitised\n}\n\n/**\n * Reject a directive sub-field that's present but not a plain object\n * (array / primitive / null). `undefined` is allowed — the field is\n * optional. Mirrors the dispatcher handler's `assertPlainObjectOrUndefined`.\n */\nfunction assertPlainObjectOrUndefined(value: unknown, label: string): void {\n if (value === undefined) return\n if (value === null || typeof value !== 'object' || Array.isArray(value)) {\n throw new Error(`withOverrides: ${label} must be an object.`)\n }\n}\n\n/**\n * Mark an entity as participating in the page-scoped override resolver.\n * See module-level JSDoc for the full contract.\n */\nexport function withOverrides(): Behavior<WithOverridesFields, 'withOverrides'> {\n const behavior: Behavior<WithOverridesFields, 'withOverrides'> & WithOverridesMarker = {\n name: 'withOverrides',\n fields: {\n // `internal: true` blocks PATCH-input poisoning (the\n // `doc:scope-override:set` op writes via updateInternal which\n // bypasses the input filter). `default: () => ({})` seeds the\n // value on every create call, so callers can omit `_overrides`\n // entirely (no `required: true` needed at the input level).\n // `refScan: true` (images PR16): replacement blocks in the map hold media\n // refs — with a placement's own spot crop — exactly as the block tree\n // does, so the usage index records them. Without it an image placed only\n // in an override was used NOWHERE as far as delete protection and the\n // spot-crop sweep could tell, and the sweep would collect the files of a\n // crop a published page still shows.\n _overrides: field.json({\n default: () => ({}),\n internal: true,\n refScan: true,\n }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Seed the empty map on create so the column is never null in DB.\n // Caller-supplied `_overrides` on create is unusual (overrides\n // are typically dispatched op-by-op) but if they DO pass one,\n // validate it.\n if (data._overrides === undefined) {\n data._overrides = {}\n } else {\n data._overrides = validateOverridesMap(data._overrides)\n }\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Only validate when the patch tries to write `_overrides`.\n if (data._overrides === undefined) return data\n data._overrides = validateOverridesMap(data._overrides)\n return data\n },\n },\n [WITH_OVERRIDES_MARKER]: true,\n }\n return behavior\n}\n\n/**\n * Check whether an entity declares `withOverrides()`. Returns `true`\n * when the entity's behaviors list contains a behavior tagged with\n * the marker symbol. Used by the boot orphan-sweep + admin UI gating.\n */\nexport function hasWithOverrides(\n behaviors: ReadonlyArray<{ name: string } & Record<symbol, unknown>> | undefined,\n): boolean {\n if (!behaviors) return false\n return behaviors.some((b) => Boolean(b?.[WITH_OVERRIDES_MARKER]))\n}\n\n/**\n * Export the directive type alias for downstream consumers that want\n * to type-narrow without depending on `@murumets-ee/blocks`. The\n * resolver lives in blocks; the persisted shape lives here.\n */\nexport type { OverrideDirective, OverridesMap }\n","/**\n * Workflowable behavior — editorial workflow with approval gates.\n *\n * Adds workflow status tracking fields to an entity and enforces the\n * documented transition state machine in `beforeUpdate`. Works alongside\n * `publishable()` — the workflow status tracks the editorial pipeline,\n * while publishable's `status` remains the public-facing state.\n *\n * Workflow state machine:\n *\n * draft ─submit─► in_review ─approve─► approved ─publish─► published\n * │ ▲ │\n * │ └────resubmit─────┐ │\n * └─reject─► rejected ─┘ │\n * │\n * unpublish ◄──────────────┘\n * (back to draft)\n *\n * Internal-write only: every workflow field is declared `internal: true`,\n * so the HTTP PATCH surface strips them from caller input. Workflow\n * transitions are performed by trusted server-side routes via\n * `AdminClient.updateInternal()` after authorizing the action with the\n * permission checker. The `beforeUpdate` hook validates that the\n * proposed transition is in the allowed table.\n *\n * Permission gating lives in the route handler (admin-ui workflow\n * routes), not in this behavior — by the time the hook fires, the\n * route has already authorized via `checkPermission`. The hook is\n * the structural defense: even a bug in the route layer cannot push\n * a workflow row into an illegal state.\n *\n * UI integration is tracked in GitHub issue #124. Until the workflow\n * UX ships, the behavior can still be added to entities — it'll simply\n * seed `_workflowStatus = 'draft'` on create and reject anyone trying\n * to write the field via PATCH.\n */\n\nimport type {\n Behavior,\n BehaviorContext,\n DateField,\n SelectField,\n TextField,\n} from '@murumets-ee/entity'\nimport { field, USER_ID_MAX_LENGTH } from '@murumets-ee/entity'\n\nexport const WORKFLOW_STATES = ['draft', 'in_review', 'approved', 'rejected', 'published'] as const\nexport type WorkflowState = (typeof WORKFLOW_STATES)[number]\n\n/**\n * Allowed transitions table. Each key is the *current* workflow status;\n * the value is the set of statuses it can move to.\n *\n * Encoded as a const-asserted record so a typo in the source produces\n * a compile error rather than a silent no-match at runtime.\n */\nexport const WORKFLOW_TRANSITIONS = {\n draft: ['in_review'],\n in_review: ['approved', 'rejected'],\n approved: ['published', 'in_review'],\n rejected: ['in_review', 'draft'],\n published: ['draft'],\n} as const satisfies Record<WorkflowState, readonly WorkflowState[]>\n\n/**\n * Is `to` an allowed transition from `from`? Pure, no I/O.\n */\nexport function isAllowedTransition(from: WorkflowState, to: WorkflowState): boolean {\n if (from === to) return true // no-op self-transition is fine\n const allowed = WORKFLOW_TRANSITIONS[from] as readonly WorkflowState[] | undefined\n return allowed?.includes(to) ?? false\n}\n\n/**\n * Thrown when `beforeUpdate` rejects a workflow transition. Distinct\n * subclass so admin-ui's API handler can map it to HTTP 400.\n */\nexport class WorkflowTransitionError extends Error {\n readonly from: WorkflowState\n readonly to: WorkflowState\n\n constructor(from: WorkflowState, to: WorkflowState) {\n super(\n `Invalid workflow transition: ${from} → ${to}. ` +\n `Allowed transitions from \"${from}\": [${(WORKFLOW_TRANSITIONS[from] ?? []).join(', ')}].`,\n )\n this.name = 'WorkflowTransitionError'\n this.from = from\n this.to = to\n }\n}\n\nexport type WorkflowableFields = {\n _workflowStatus: SelectField & { required: true }\n _workflowAssignee: TextField\n _workflowNote: TextField\n _workflowUpdatedAt: DateField\n}\n\nexport function workflowable(): Behavior<WorkflowableFields> {\n return {\n name: 'workflowable',\n fields: {\n _workflowStatus: field.select({\n options: WORKFLOW_STATES as unknown as string[],\n default: 'draft',\n indexed: true,\n required: true,\n internal: true,\n }) as SelectField & { required: true },\n // `_workflowAssignee` stores a better-auth user ID — varchar(255)\n // per CLAUDE.md §\"User IDs\", emitted via `maxLength: 255` (#430).\n // `_workflowNote` is free-text, so it stays unbounded `text`.\n _workflowAssignee: field.text({\n required: false,\n internal: true,\n maxLength: USER_ID_MAX_LENGTH,\n }),\n _workflowNote: field.text({ required: false, internal: true }),\n _workflowUpdatedAt: field.date({ internal: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._workflowStatus = 'draft'\n data._workflowUpdatedAt = new Date()\n return data\n },\n beforeUpdate: async (\n _id: string,\n data: Record<string, unknown>,\n ctx?: BehaviorContext,\n ): Promise<Record<string, unknown>> => {\n // Only validate when the patch tries to change the workflow status.\n // Updates that don't touch _workflowStatus pass through (the field's\n // `internal: true` already prevents it from sneaking in via PATCH;\n // only updateInternal callers reach this branch with a value).\n const proposed = data._workflowStatus\n if (proposed === undefined) return data\n\n if (!WORKFLOW_STATES.includes(proposed as WorkflowState)) {\n throw new WorkflowTransitionError('draft', proposed as WorkflowState)\n }\n\n // Need the current row to validate the from→to transition. The\n // lazy loader is provided by AdminClient — present on update,\n // absent on create.\n const current = await ctx?.loadCurrent?.()\n const fromState = (current?._workflowStatus as WorkflowState | undefined) ?? 'draft'\n const toState = proposed as WorkflowState\n\n if (!isAllowedTransition(fromState, toState)) {\n throw new WorkflowTransitionError(fromState, toState)\n }\n\n data._workflowUpdatedAt = new Date()\n return data\n },\n },\n }\n}\n","/**\n * Per-entity content capability gates.\n *\n * The content plugin attaches different infrastructure tables to an\n * entity based on which behaviors it has:\n * - `publishable()` → drafts table\n * - `versionable()` → versions table\n * - `publishable() + translatable fields` → locale_status table\n * - any translatable field → translations table (also written by the\n * entity package; included for symmetric capability checks)\n *\n * Calling a capability-specific ContentClient method (`saveDraft`,\n * `publishDraft`, `setLocaleStatus`, `restoreVersion`, ...) on an\n * entity that doesn't have the required behavior is a programming\n * error. ContentClient throws `EntityCapabilityError` on writes and\n * silently returns falsy on reads. Callers that want to gate ahead —\n * \"should I show the draft button on this entity row?\" — use\n * `entitySupports()` instead of catching the error.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\nexport type EntityCapability = 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n/**\n * Does this entity support the given content capability? Pure check\n * against the entity's declared behaviors and field translatability —\n * no plugin lookup, no DB call.\n */\nexport function entitySupports(entity: Entity, capability: EntityCapability): boolean {\n switch (capability) {\n case 'drafts':\n return hasBehavior(entity, 'publishable')\n case 'versions':\n return hasBehavior(entity, 'versionable')\n case 'localeStatus':\n return hasBehavior(entity, 'publishable') && hasTranslatableField(entity)\n case 'translations':\n return hasTranslatableField(entity)\n }\n}\n\nfunction hasBehavior(entity: Entity, name: string): boolean {\n return entity.behaviors?.some((b) => b.name === name) ?? false\n}\n\nfunction hasTranslatableField(entity: Entity): boolean {\n const fields = entity.allFields as Record<string, FieldConfig>\n for (const key of Object.keys(fields)) {\n const f = fields[key]\n if (f && (f as { translatable?: boolean }).translatable === true) return true\n }\n return false\n}\n","/**\n * The interface-message **registry resolver** (plan/config phase 06, D004/D010).\n *\n * The effective interface strings = **read-time-merge** of the per-package / per-app\n * message JSON defaults ⊕ the DB `message` config-entity override rows. This resolver\n * returns the OVERRIDE rows for one locale as flat {@link MessageEntry}s; the caller\n * (the admin `i18n/request.ts`) nests them via `overridesToCatalog` and passes the\n * result as the last `mergeMessages(...)` argument so DB overrides win — live edits,\n * no rebuild.\n *\n * The read is a **system-context** query on the read-only connection: interface strings\n * must reflect customizations on EVERY request (incl. the unauthenticated login page's\n * `AuthUI` strings), exactly like the app-wide locale/roles reads. It degrades to \"no\n * overrides\" (shipped defaults only) on any error — a fresh app pre-migration or a\n * transient DB blip simply renders the shipped strings.\n */\n\nimport { createSystemReachResolver, type ToolkitApp } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport type { MessageEntry } from '@murumets-ee/i18n'\nimport { eq } from 'drizzle-orm'\nimport { Message } from './message-entity.js'\n\n/**\n * Elevated **reach** resolver for the app-wide message read (the locale/roles-read\n * analog). Grants `view` and nothing else: this module only READS override rows,\n * and the editor route that writes them is separately gated. Named constructor\n * rather than a `checker: () => true` literal so the identity is enumerable —\n * see the elevation allowlist in `scripts/verify-package-boundaries.mjs`.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('message-registry')\n\n/**\n * Defensive upper bound on override rows read for one locale. A fully-translated app\n * across several locales legitimately accumulates thousands of rows, but per-locale the\n * count is bounded by the number of *customized* strings. The read is `log()`-ged when it\n * hits the cap so a silent truncation can't read as \"all overrides applied\" (CLAUDE.md\n * no-unbounded-fan-out rule).\n */\nconst MAX_MESSAGE_OVERRIDE_ROWS = 20000\n\n/**\n * The DB `message` override rows for `locale`, as flat `(namespace, key, value)` entries.\n * Empty when the `message` entity isn't registered (no `messageEditor()` / pre-migration)\n * or on any read error.\n */\nexport async function resolveMessageOverrides(\n app: ToolkitApp,\n locale: string,\n): Promise<MessageEntry[]> {\n if (!app.entities?.has('message')) return []\n const table = schemaRegistry.get('message')\n if (!table) return []\n\n const client = new QueryClient({\n entity: Message,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'message' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n\n try {\n const rows = await client.findMany({\n where: eq(table.locale, locale),\n limit: MAX_MESSAGE_OVERRIDE_ROWS,\n })\n if (rows.length >= MAX_MESSAGE_OVERRIDE_ROWS) {\n app.logger.warn(\n { locale, cap: MAX_MESSAGE_OVERRIDE_ROWS },\n 'message override read hit the row cap — some overrides were not applied',\n )\n }\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n namespace: String(row.namespace),\n key: String(row.key),\n value: String(row.value),\n }\n })\n } catch (err) {\n app.logger.warn(\n { err, locale },\n 'message registry read failed — falling back to shipped defaults only',\n )\n return []\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"locale-entity-CoSq3C77.mjs","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts"],"sourcesContent":["/**\n * `i18n` — the global i18n singletons **simple-config** namespace (plan/config\n * phase 05 §3, D002/D009/SD006): `defaultLocale` + `routingStrategy`.\n *\n * Simple config (D002) rather than a config entity because these are singletons,\n * not a list. Contributed via the content plugin's `shared.settings` slot, so it\n * auto-registers as a config type (D006) and rides export/import (phase 09) for\n * free. Edited through the bespoke **Languages** editor (`hideFromMenu`), which\n * also enforces `defaultLocale ∈ enabled locales`.\n *\n * Imports from `@murumets-ee/settings/define` (the pure builder subpath, no\n * `server-only`) so the content plugin stays jiti-safe at config-load time.\n */\n\nimport { defineSettings, setting } from '@murumets-ee/settings/define'\n\n/** The three URL locale-routing strategies (D009). */\nexport const ROUTING_STRATEGIES = ['prefix-all', 'no-prefix-default', 'cookie'] as const\n\nexport const i18nSettings = defineSettings({\n namespace: 'i18n',\n scope: 'global',\n label: 'Languages',\n iconName: 'languages',\n // Edited via the Languages editor, not the generic per-namespace settings page.\n hideFromMenu: true,\n schema: {\n /**\n * The site-wide default content locale. Empty ⇒ inherit the `content()`\n * shipped `defaultLocale`. The resolver ({@link resolveI18nConfig}) validates\n * it is an **enabled** locale and falls back to the `content()` default if not.\n */\n defaultLocale: setting.text({ default: '' }),\n /**\n * How the public URL encodes locale. Maps to next-intl `localePrefix` /\n * Astro `prefixDefaultLocale` via `./routing-strategy.js`.\n */\n routingStrategy: setting.select({\n options: ROUTING_STRATEGIES,\n default: 'no-prefix-default',\n }),\n },\n})\n","/**\n * `locale` — the content-locale registry **config entity** (plan/config phase 05\n * §3, D006/D009/SD006).\n *\n * One row per content locale the operator adds or customizes. The row is a\n * **read-time-merge override** (D004): a row whose `code` matches a `content()`\n * shipped default replaces that default (relabel / set direction / reorder /\n * `enabled: false` to hide it); a row with a new `code` adds a locale. Locales\n * with no row are the shipped defaults. See {@link resolveEffectiveLocales}.\n *\n * `class: 'config'` (SD001) marks it a configuration entity: it auto-registers\n * into the `ConfigTypeRegistry` (phase-09 export/import picks it up for free),\n * defaults to audit-only (no publish / no trash — trash auto-enable skips config\n * entities), and is edited through the bespoke **Languages** editor, not the\n * generic auto-CRUD (`admin.hidden`).\n *\n * `access` is admin-only: managing the language set is an admin capability. The\n * app-wide *effective-locale read* ({@link resolveEffectiveLocales}) uses a\n * system-context query so every role still resolves valid locales for editing\n * content, without being able to list/edit the registry itself.\n *\n * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin can pull\n * it in without a `server-only` chain through the jiti config load.\n */\n\nimport { behavior, defineEntity, field } from '@murumets-ee/entity'\n\nexport const Locale = defineEntity({\n name: 'locale',\n class: 'config',\n scope: 'global',\n // The locale registry is global; each site lists the subset it serves (D007).\n site: 'shared',\n access: {\n view: 'locale:view',\n create: 'locale:create',\n update: 'locale:update',\n delete: 'locale:delete',\n },\n fields: {\n /**\n * BCP47 locale code (e.g. `en`, `et`, `pt-BR`). Unique — one row per locale.\n * Capped at 10 to match the `varchar(10)` locale columns in the\n * versions / drafts / locale_status tables (truncation past 10 would break\n * later equality — see `locale-utils.ts`).\n */\n code: field.text({ required: true, unique: true, maxLength: 10 }),\n /** Human label shown in the switcher + editor (e.g. `English`). */\n label: field.text({ required: true, maxLength: 64 }),\n /**\n * When `false` the locale is hidden from the content switcher + rejected by\n * `validateLocale` (the D004 disable-not-delete edge), but stays listable in\n * the Languages editor so it can be re-enabled.\n */\n enabled: field.boolean({ default: true }),\n /** Text direction (`ltr`/`rtl`) — persisted per locale for RTL-aware surfaces to read\n * when setting `dir`; the surface-level `dir` wiring lands with RTL support. */\n direction: field.select({ options: ['ltr', 'rtl'] as const, default: 'ltr' }),\n /** Display order in the switcher + editor (ascending). */\n sortOrder: field.number({ default: 0 }),\n },\n behaviors: [behavior.auditable()],\n admin: {\n // Edited via the Languages editor, not the generic CRUD — keep it out of the\n // auto entity list + sidebar.\n hidden: true,\n label: 'Languages',\n labelSingular: 'Language',\n },\n})\n"],"mappings":"uJAiBA,MAAa,EAAqB,CAAC,aAAc,oBAAqB,QAAQ,EAEjE,EAAe,EAAe,CACzC,UAAW,OACX,MAAO,SACP,MAAO,YACP,SAAU,YAEV,aAAc,GACd,OAAQ,CAMN,cAAe,EAAQ,KAAK,CAAE,QAAS,EAAG,CAAC,EAK3C,gBAAiB,EAAQ,OAAO,CAC9B,QAAS,EACT,QAAS,mBACX,CAAC,CACH,CACF,CAAC,ECfY,EAAS,EAAa,CACjC,KAAM,SACN,MAAO,SACP,MAAO,SAEP,KAAM,SACN,OAAQ,CACN,KAAM,cACN,OAAQ,gBACR,OAAQ,gBACR,OAAQ,eACV,EACA,OAAQ,CAON,KAAM,EAAM,KAAK,CAAE,SAAU,GAAM,OAAQ,GAAM,UAAW,EAAG,CAAC,EAEhE,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,EAAG,CAAC,EAMnD,QAAS,EAAM,QAAQ,CAAE,QAAS,EAAK,CAAC,EAGxC,UAAW,EAAM,OAAO,CAAE,QAAS,CAAC,MAAO,KAAK,EAAY,QAAS,KAAM,CAAC,EAE5E,UAAW,EAAM,OAAO,CAAE,QAAS,CAAE,CAAC,CACxC,EACA,UAAW,CAAC,EAAS,UAAU,CAAC,EAChC,MAAO,CAGL,OAAQ,GACR,MAAO,YACP,cAAe,UACjB,CACF,CAAC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"locale-registry-COsJrVZl.mjs","names":[],"sources":["../src/locale-registry.ts"],"sourcesContent":["/**\n * The content-locale **registry resolvers** (plan/config phase 05 §7, D004/D009/SD006).\n *\n * The effective content-locale set = **read-time-merge** of the `content()` shipped\n * defaults ⊕ the DB `locale` config-entity override rows. Resolved **once per request**\n * at context establishment (the admin API handler + `withAdminContext`) and threaded\n * onto {@link RequestContext.availableLocales}, so `validateLocale`'s synchronous\n * whitelist reflects runtime-added / disabled locales with **no rebuild and no\n * globalThis cache** — multi-process / multi-tenant correct.\n *\n * The registry read is a **system-context** query: managing the language set is\n * admin-only (`locale:*`), but every role must resolve valid locales to edit content,\n * exactly like the app-wide roles read. Reads degrade to the static `content()`\n * defaults on any error (fresh app pre-migration, transient DB failure) — deny-by-\n * default is preserved (a not-yet-known locale simply isn't whitelisted).\n */\n\nimport {\n createSystemReachResolver,\n getApp,\n getCurrentSite,\n mergeConfigOverrides,\n readSiteRecord,\n resolveImplicitSite,\n type SiteRecordView,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { getContentConfig } from './content-config-store.js'\nimport { i18nSettings, ROUTING_STRATEGIES } from './i18n-settings.js'\nimport { Locale } from './locale-entity.js'\nimport type { I18nConfig, LocaleDirection, LocaleEntry, RoutingStrategy } from './types.js'\n\n/**\n * Elevated **reach** resolver for the app-wide registry read (the roles-read\n * analog). It grants `view` and nothing else — this module only ever READS the\n * `locale` rows, so an all-allow checker would have handed every caller of this\n * resolver a write grant it has no use for. The named constructor owns that\n * exception centrally rather than leaving it to be re-derived per call site;\n * a hand-written `checker: () => true` here is refused by `verify:boundaries`.\n *\n * The explicit id is the audit identity — two elevated readers sharing the\n * default `'system'` are indistinguishable in the log.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('locale-registry')\n\n/** The `content()`-shipped locales projected to full {@link LocaleEntry}s (all enabled). */\nfunction shippedDefaults(): LocaleEntry[] {\n return getContentConfig().locales.map((l, i) => ({\n code: l.code,\n label: l.label,\n enabled: true,\n direction: 'ltr' as LocaleDirection,\n sortOrder: i,\n }))\n}\n\n/** Ascending by `sortOrder`, then `code` for a stable tie-break. */\nfunction bySortOrder(a: LocaleEntry, b: LocaleEntry): number {\n return a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)\n}\n\n/**\n * Defensive upper bound on `locale` rows to read (a tenant realistically has <20). The\n * Languages-editor route MUST reconcile against the SAME bound so it can't miss/strand\n * rows — see admin-ui `locale-routes` `MAX_EXISTING_LOCALE_ROWS` (kept in sync).\n */\nconst MAX_LOCALE_ROWS = 1000\n\n/** Read + normalize the DB `locale` override rows (system context). */\nasync function readLocaleRows(app: ToolkitApp): Promise<LocaleEntry[]> {\n if (!app.entities?.has('locale')) return []\n const client = new QueryClient({\n entity: Locale,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'locale' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n const rows = await client.findMany({ limit: MAX_LOCALE_ROWS })\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n code: String(row.code),\n label: typeof row.label === 'string' && row.label.length > 0 ? row.label : String(row.code),\n enabled: row.enabled !== false,\n direction: row.direction === 'rtl' ? 'rtl' : 'ltr',\n sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : 0,\n }\n })\n}\n\n/**\n * The effective content-locale set — shipped defaults ⊕ DB overrides, sorted by\n * `sortOrder`. Includes **disabled** locales (listable in the Languages editor);\n * callers that want only switchable/valid locales use {@link enabledLocaleCodes}.\n */\nexport async function resolveEffectiveLocales(app: ToolkitApp): Promise<LocaleEntry[]> {\n let rows: LocaleEntry[]\n try {\n rows = await readLocaleRows(app)\n } catch (err) {\n app.logger.warn({ err }, 'locale registry read failed — falling back to content() defaults')\n return [...shippedDefaults()].sort(bySortOrder)\n }\n const merged = mergeConfigOverrides<LocaleEntry, LocaleEntry>({\n defaults: shippedDefaults(),\n overrides: rows,\n idOfDefault: (l) => l.code,\n idOfOverride: (r) => r.code,\n // No `isDisabled`: a disabled locale stays in the merged list so the editor\n // can re-enable it — the switcher + validateLocale filter on `enabled`.\n })\n return (merged as LocaleEntry[]).sort(bySortOrder)\n}\n\n/** The **enabled** codes from an effective set — the switcher + validateLocale whitelist. */\nexport function enabledLocaleCodes(entries: readonly LocaleEntry[]): string[] {\n return entries.filter((e) => e.enabled).map((e) => e.code)\n}\n\n/**\n * The resolved global i18n singletons (`i18n` settings ⊕ `content()` defaults).\n * `defaultLocale` is guaranteed to be an **enabled** locale: the configured value\n * wins if enabled, else the `content()` default if enabled, else the first enabled\n * locale (last-resort, deterministic). Pass `entries` to reuse an already-resolved\n * effective set (avoids a second registry read within one request).\n */\nexport async function resolveI18nConfig(\n app: ToolkitApp,\n entries?: readonly LocaleEntry[],\n): Promise<I18nConfig> {\n const effective = entries ?? (await resolveEffectiveLocales(app))\n const enabled = enabledLocaleCodes(effective)\n const shippedDefault = getContentConfig().defaultLocale\n\n let values: Record<string, unknown> = {}\n try {\n // Dynamic import: `@murumets-ee/settings/runtime` evaluates the settings schema\n // (`defineTable`) at module load; importing it eagerly would drag that graph into\n // `@murumets-ee/content`'s main index (where only `resolveEffectiveLocales` — which\n // needs no settings — is on the hot import path). Loaded lazily here, only when the\n // i18n singletons are actually read. (Same pattern as rent/ticketing/consent.)\n const { createSettingsClient } = await import('@murumets-ee/settings/runtime')\n const client = createSettingsClient(i18nSettings, { app })\n values = (await client.getAll()) ?? {}\n } catch (err) {\n app.logger.warn({ err }, 'i18n settings read failed — falling back to content() defaults')\n }\n\n const configuredDefault =\n typeof values.defaultLocale === 'string' && values.defaultLocale.length > 0\n ? values.defaultLocale\n : shippedDefault\n\n const defaultLocale = enabled.includes(configuredDefault)\n ? configuredDefault\n : enabled.includes(shippedDefault)\n ? shippedDefault\n : (enabled[0] ?? shippedDefault)\n\n const rawStrategy = values.routingStrategy\n const routingStrategy: RoutingStrategy = (ROUTING_STRATEGIES as readonly string[]).includes(\n typeof rawStrategy === 'string' ? rawStrategy : '',\n )\n ? (rawStrategy as RoutingStrategy)\n : 'no-prefix-default'\n\n return { defaultLocale, routingStrategy }\n}\n\n/** What {@link resolveSiteI18n} returns. */\nexport interface SiteI18n {\n /** The site resolved, or `undefined` when none could be named (see below). */\n site: string | undefined\n /** The effective registry narrowed to the site (disabled locales kept). */\n entries: LocaleEntry[]\n /**\n * The site's PRESENTATION defaults: the locale a switcher, a sitemap or an\n * unlocalised request falls back to (an ENABLED locale of the site), and its\n * routing strategy.\n *\n * ⚠️ Not the STORAGE default. Which locale a row's base columns hold is an\n * install-wide fact (`content({ defaultLocale })`, `i18n.defaultLocale`) —\n * shared rows (media) are written from every site, so it cannot vary by\n * site. Read {@link install} for it.\n */\n i18n: I18nConfig\n /** The install-wide i18n singletons — the storage default. */\n install: I18nConfig\n}\n\n/** The site of `options.site`, the request, or the one site of a single-site install. */\nasync function nameSite(app: ToolkitApp, site: string | undefined): Promise<string | undefined> {\n return site ?? getCurrentSite() ?? (await resolveImplicitSite(app).catch(() => undefined))\n}\n\n/**\n * The site's registry row. A failed read PROPAGATES: answering the install-wide\n * set instead would silently lift the site's locale restriction (fail open).\n */\nfunction siteRecord(app: ToolkitApp, site: string): Promise<SiteRecordView | undefined> {\n return readSiteRecord(app, site)\n}\n\nfunction narrowTo(effective: LocaleEntry[], record: SiteRecordView | undefined): LocaleEntry[] {\n const served = record?.locales ?? []\n return served.length === 0 ? effective : effective.filter((e) => served.includes(e.code))\n}\n\n/**\n * The effective registry narrowed to ONE SITE's locales (plan `multisite`\n * phase 03) — the whitelist alone, without the i18n settings read, for the\n * per-request callers (the admin API handler) that need only the set.\n */\nexport async function resolveSiteLocales(\n app: ToolkitApp,\n options: { site?: string; entries?: readonly LocaleEntry[] } = {},\n): Promise<{ site: string | undefined; entries: LocaleEntry[] }> {\n const effective = [...(options.entries ?? (await resolveEffectiveLocales(app)))]\n const site = await nameSite(app, options.site)\n if (site === undefined) return { site, entries: effective }\n return { site, entries: narrowTo(effective, await siteRecord(app, site)) }\n}\n\n/**\n * The locale set and presentation defaults of ONE SITE (plan `multisite` D007,\n * phase 03): the effective registry narrowed to the site record's `locales`\n * (empty = every locale), with the site's own default and routing strategy.\n *\n * - `entries` keeps disabled locales (like {@link resolveEffectiveLocales});\n * the whitelist is `enabledLocaleCodes(entries)`.\n * - `i18n.defaultLocale` is an ENABLED locale of the site. On an app serving\n * several sites the record's `defaultLocale` / `routingStrategy` win; on a\n * single-site install the install's i18n settings stay authoritative (the\n * record only holds a one-time copy of them, and the Languages editor writes\n * the settings), so nothing about a one-site install changes.\n * - The site is `options.site`, else the request's, else the single site of a\n * one-site install. With none of those (a multi-site request that names no\n * site) the install-wide set is returned — what a SHARED entity (media) edits\n * against; a bound entity refuses on its own, without a site.\n */\nexport async function resolveSiteI18n(\n app: ToolkitApp,\n options: { site?: string; entries?: readonly LocaleEntry[] } = {},\n): Promise<SiteI18n> {\n const effective = [...(options.entries ?? (await resolveEffectiveLocales(app)))]\n const install = await resolveI18nConfig(app, effective)\n const site = await nameSite(app, options.site)\n if (site === undefined) return { site, entries: effective, i18n: install, install }\n\n const record = await siteRecord(app, site)\n const narrowed = narrowTo(effective, record)\n const enabled = enabledLocaleCodes(narrowed)\n const multiSite = app.config.sites?.multiple === true\n const wanted = multiSite ? record?.defaultLocale : undefined\n const defaultLocale =\n wanted !== undefined && enabled.includes(wanted)\n ? wanted\n : enabled.includes(install.defaultLocale)\n ? install.defaultLocale\n : (enabled[0] ?? install.defaultLocale)\n const strategy = multiSite ? record?.routingStrategy : undefined\n const routingStrategy: RoutingStrategy = (ROUTING_STRATEGIES as readonly string[]).includes(\n strategy ?? '',\n )\n ? (strategy as RoutingStrategy)\n : install.routingStrategy\n return { site, entries: narrowed, i18n: { defaultLocale, routingStrategy }, install }\n}\n\n/**\n * The install's base language for the `'sites.baseLocale'` contribution\n * (multisite F034) — the default of {@link resolveI18nConfig}, which is the\n * language every row's base columns hold. `undefined` when the app has no\n * language registry, so the site registry's guard then applies no rule: the\n * same answer the Sites API gives.\n */\nexport async function resolveInstallBaseLocale(): Promise<string | undefined> {\n const app = getApp()\n if (!app.entities.has('locale')) return undefined\n return (await resolveI18nConfig(app)).defaultLocale\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"locale-utils-B0mFoV47.mjs","names":[],"sources":["../src/locale-utils.ts"],"sourcesContent":["/**\n * Locale validation helpers.\n *\n * Every ContentClient and LockService method that accepts a `locale`\n * parameter must run it through `validateLocale()` before passing it to\n * the database. Drizzle parameterizes queries so SQL injection isn't a\n * concern, but unvalidated locale strings:\n * - bypass the configured locale set (typo'd `'frr'` writes a row that\n * never resolves on read),\n * - silently truncate at the column's `varchar(10)` boundary (e.g.\n * `'pt-BR-traditional'` → `'pt-BR-trad'`, breaking later equality),\n * - pollute drafts/versions/locale_status tables with rows that no\n * UI ever queries.\n *\n * The `'_'` sentinel is allowed because it's the conventional value for\n * default-locale rows in versions/drafts (where every row needs a\n * non-null `locale` for unique-constraint composition).\n */\n\nimport { getContext } from '@murumets-ee/core'\n// The LEAF definition, not the `./plugin.js` re-export — `plugin.ts` imports\n// this very module (indirectly, through `document-version-store.ts`'s\n// contribution), so importing `getContentConfig` through the barrel would\n// close a cycle. `plugin.ts` re-exports it only for external consumers of\n// `@murumets-ee/content/plugin`; internal modules use the leaf directly.\nimport { getContentConfig } from './content-config-store.js'\nimport { InvalidLocaleError } from './errors.js'\n\n/**\n * The internal sentinel for \"default locale\" used in version/draft storage.\n * `@murumets-ee/entity/admin` exports the same constant (where\n * `updateForLocale` defines the cross-package contract); the `satisfies`\n * pins the two literals together at compile time — drift on either side is\n * a type error — without a runtime import (which would force every unit\n * test that mocks `entity/admin` to spread `importOriginal`).\n */\nexport const DEFAULT_LOCALE_SENTINEL = '_' satisfies typeof import(\n '@murumets-ee/entity/admin'\n)['DEFAULT_LOCALE_SENTINEL']\n\n/**\n * Throw `InvalidLocaleError` if `locale` is neither the `'_'` sentinel nor one of\n * the **effective enabled** content-locale codes for the current request.\n *\n * The allowed set is the request-scoped {@link RequestContext.availableLocales}\n * (plan/config phase 05 §7, SD006) — the read-time-merge of `content()` shipped\n * defaults ⊕ DB `locale` override rows, resolved once per request at context\n * establishment. So a locale added/enabled at runtime validates with **no rebuild**,\n * and a disabled one is rejected — without a globalThis cache (multi-process /\n * multi-tenant correct).\n *\n * **Fallback:** outside a request context (build-time, CLI, unit tests, or the rare\n * concurrent-render ALS-scope escape) `availableLocales` is absent, so the check\n * falls back to the static `content()` codes — same behavior as before SD006, and\n * still deny-by-default (an unknown locale rejects either way).\n */\nexport function validateLocale(locale: string): void {\n if (locale === DEFAULT_LOCALE_SENTINEL) return\n const allowed = getContext()?.availableLocales ?? getContentConfig().locales.map((l) => l.code)\n for (const code of allowed) {\n if (code === locale) return\n }\n throw new InvalidLocaleError(locale, [...allowed])\n}\n"],"mappings":"4IAwDA,SAAgB,EAAe,EAAsB,CACnD,GAAI,IAAA,IAAoC,OACxC,IAAM,EAAU,EAAW,CAAC,EAAE,kBAAoB,EAAiB,CAAC,CAAC,QAAQ,IAAK,GAAM,EAAE,IAAI,EAC9F,IAAK,IAAM,KAAQ,EACjB,GAAI,IAAS,EAAQ,OAEvB,MAAM,IAAI,EAAmB,EAAQ,CAAC,GAAG,CAAO,CAAC,CACnD"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"lock-service.d.mts","names":[],"sources":["../src/lock-service.ts"],"mappings":";;;UAqBiB,iBAAA;EACf,GAAA,EAAK,UAAU;AAAA;AAAA,cAGJ,WAAA;EAAA,QACH,MAAA;cAEI,MAAA,EAAQ,iBAAA;EAqG0D;;;;;;;;;;;;;;;;;EAhFxE,WAAA,CACJ,UAAA,UACA,QAAA,UACA,MAAA,UACA,IAAA;IAAQ,EAAA;IAAY,IAAA;EAAA,IACnB,OAAA;IAAU,QAAA;EAAA;IAAqB,QAAA;IAAiB,IAAA,EAAM,QAAA;EAAA;EA8CtD;;;;EALG,SAAA,CACJ,UAAA,UACA,QAAA,UACA,MAAA,UACA,MAAA,WACC,OAAA;EAmBD;;;EAJI,WAAA,CACJ,UAAA,UACA,QAAA,UACA,MAAA,UACA,MAAA,WACC,OAAA;EASwC;;;;EAArC,gBAAA,CAAiB,UAAA,UAAoB,QAAA,UAAkB,MAAA,WAAiB,OAAA;EAS5C;;;;EAA5B,OAAA,CAAQ,UAAA,UAAoB,QAAA,UAAkB,MAAA,WAAiB,OAAA,CAAQ,QAAA;AAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"lock-service.mjs","names":[],"sources":["../src/lock-service.ts"],"sourcesContent":["/**\n * Content lock service — pessimistic locking for entity editing.\n *\n * Prevents concurrent edit conflicts by allowing only one editor at a time\n * per entity+locale combination. Locks expire after 5 minutes and must\n * be renewed via heartbeat.\n *\n * Uses TableClient for all DB operations — zero direct Drizzle queries.\n */\n\nimport 'server-only'\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { contentLocksTable } from './schema/locks.js'\nimport type { LockInfo } from './types.js'\n\n/** Lock duration in minutes */\nconst LOCK_DURATION_MINUTES = 5\n\ntype LockClient = ReturnType<typeof contentLocksTable.makeClient>\n\nexport interface LockServiceConfig {\n app: ToolkitApp\n}\n\nexport class LockService {\n private client: LockClient\n\n constructor(config: LockServiceConfig) {\n this.client = contentLocksTable.makeClient(config.app.db.readWrite)\n }\n\n /**\n * Acquire a lock on an entity+locale, atomically.\n *\n * Implemented as a single `INSERT … ON CONFLICT DO UPDATE … WITH SET WHERE`\n * via {@link TableClient.tryClaim} — no `deleteMany → findOne → insert`\n * race. Two concurrent acquires of the same free slot produce exactly\n * one `acquired: true` and one `acquired: false`; the loser sees the\n * winner's row.\n *\n * Replace semantics: take the slot if **any** of these is true:\n * - the slot is empty (INSERT path),\n * - the existing row is past its expiry (`expiresAt < now`),\n * - the existing row is held by the same user (re-entrant renew).\n *\n * Otherwise — fresh lock by another user — return `{ acquired: false,\n * lock }` so the caller can render \"X is editing\".\n */\n async acquireLock(\n entityType: string,\n entityId: string,\n locale: string,\n user: { id: string; name?: string },\n ): Promise<{ acquired: true } | { acquired: false; lock: LockInfo }> {\n const now = new Date()\n const expiresAt = new Date(now.getTime() + LOCK_DURATION_MINUTES * 60 * 1000)\n\n const result = await this.client.tryClaim(\n {\n entityType,\n entityId,\n locale,\n lockedBy: user.id,\n lockedByName: user.name ?? null,\n lockedAt: now,\n expiresAt,\n },\n {\n target: ['entityType', 'entityId', 'locale'],\n // Replace if expired OR if I already hold this slot. Using $or\n // lets re-entrancy and stale-takeover share one atomic statement.\n replaceWhen: {\n $or: [{ expiresAt: { lt: now } }, { lockedBy: user.id }],\n },\n },\n )\n\n if (result.acquired) return { acquired: true }\n\n return {\n acquired: false,\n lock: {\n lockedBy: result.row.lockedBy,\n lockedByName: result.row.lockedByName ?? null,\n lockedAt: result.row.lockedAt,\n expiresAt: result.row.expiresAt,\n },\n }\n }\n\n /**\n * Renew an existing lock (heartbeat).\n * Only succeeds if the caller holds the lock and it hasn't expired.\n */\n async renewLock(\n entityType: string,\n entityId: string,\n locale: string,\n userId: string,\n ): Promise<boolean> {\n const now = new Date()\n const expiresAt = new Date(now.getTime() + LOCK_DURATION_MINUTES * 60 * 1000)\n\n const updated = await this.client.update(\n { entityType, entityId, locale, lockedBy: userId, expiresAt: { gte: now } },\n { expiresAt },\n )\n\n return updated !== null\n }\n\n /**\n * Release a lock. Only the lock holder can release their own lock.\n */\n async releaseLock(\n entityType: string,\n entityId: string,\n locale: string,\n userId: string,\n ): Promise<boolean> {\n const deleted = await this.client.delete({ entityType, entityId, locale, lockedBy: userId })\n return deleted !== null\n }\n\n /**\n * Force-release a lock regardless of who holds it.\n * Admin-only operation — must be audit-logged by the caller.\n */\n async forceReleaseLock(entityType: string, entityId: string, locale: string): Promise<boolean> {\n const deleted = await this.client.delete({ entityType, entityId, locale })\n return deleted !== null\n }\n\n /**\n * Get the current lock for an entity+locale.\n * Returns null if no lock or lock has expired.\n */\n async getLock(entityType: string, entityId: string, locale: string): Promise<LockInfo | null> {\n const now = new Date()\n\n const row = await this.client.findOne({\n entityType,\n entityId,\n locale,\n expiresAt: { gte: now },\n })\n\n if (!row) return null\n\n return {\n lockedBy: row.lockedBy,\n lockedByName: row.lockedByName ?? null,\n lockedAt: row.lockedAt as Date,\n expiresAt: row.expiresAt as Date,\n }\n }\n}\n"],"mappings":"6DAyBA,IAAa,EAAb,KAAyB,CACvB,OAEA,YAAY,EAA2B,CACrC,KAAK,OAAS,EAAkB,WAAW,EAAO,IAAI,GAAG,SAAS,CACpE,CAmBA,MAAM,YACJ,EACA,EACA,EACA,EACmE,CACnE,IAAM,EAAM,IAAI,KACV,EAAY,IAAI,KAAK,EAAI,QAAQ,EAAI,IAA6B,GAAI,EAEtE,EAAS,MAAM,KAAK,OAAO,SAC/B,CACE,aACA,WACA,SACA,SAAU,EAAK,GACf,aAAc,EAAK,MAAQ,KAC3B,SAAU,EACV,WACF,EACA,CACE,OAAQ,CAAC,aAAc,WAAY,QAAQ,EAG3C,YAAa,CACX,IAAK,CAAC,CAAE,UAAW,CAAE,GAAI,CAAI,CAAE,EAAG,CAAE,SAAU,EAAK,EAAG,CAAC,CACzD,CACF,CACF,EAIA,OAFI,EAAO,SAAiB,CAAE,SAAU,EAAK,EAEtC,CACL,SAAU,GACV,KAAM,CACJ,SAAU,EAAO,IAAI,SACrB,aAAc,EAAO,IAAI,cAAgB,KACzC,SAAU,EAAO,IAAI,SACrB,UAAW,EAAO,IAAI,SACxB,CACF,CACF,CAMA,MAAM,UACJ,EACA,EACA,EACA,EACkB,CAClB,IAAM,EAAM,IAAI,KACV,EAAY,IAAI,KAAK,EAAI,QAAQ,EAAI,IAA6B,GAAI,EAO5E,OAAO,MALe,KAAK,OAAO,OAChC,CAAE,aAAY,WAAU,SAAQ,SAAU,EAAQ,UAAW,CAAE,IAAK,CAAI,CAAE,EAC1E,CAAE,WAAU,CACd,IAEmB,IACrB,CAKA,MAAM,YACJ,EACA,EACA,EACA,EACkB,CAElB,OAAO,MADe,KAAK,OAAO,OAAO,CAAE,aAAY,WAAU,SAAQ,SAAU,CAAO,CAAC,IACxE,IACrB,CAMA,MAAM,iBAAiB,EAAoB,EAAkB,EAAkC,CAE7F,OAAO,MADe,KAAK,OAAO,OAAO,CAAE,aAAY,WAAU,QAAO,CAAC,IACtD,IACrB,CAMA,MAAM,QAAQ,EAAoB,EAAkB,EAA0C,CAC5F,IAAM,EAAM,IAAI,KAEV,EAAM,MAAM,KAAK,OAAO,QAAQ,CACpC,aACA,WACA,SACA,UAAW,CAAE,IAAK,CAAI,CACxB,CAAC,EAID,OAFK,EAEE,CACL,SAAU,EAAI,SACd,aAAc,EAAI,cAAgB,KAClC,SAAU,EAAI,SACd,UAAW,EAAI,SACjB,EAPiB,IAQnB,CACF"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"locks-5779ugSW.mjs","names":[],"sources":["../src/schema/locks.ts"],"sourcesContent":["/**\n * Content lock table schema\n *\n * Single shared table (not per-entity) for pessimistic content locking.\n * Prevents concurrent edit conflicts by allowing only one editor at a time\n * per entity+locale combination.\n *\n * Locks expire after 5 minutes and must be renewed via heartbeat.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const contentLocksTable = defineTable({\n name: 'toolkit_content_locks',\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 locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n lockedBy: column.varchar({ length: 255, notNull: true, pgName: 'locked_by' }),\n lockedByName: column.varchar({ length: 255, pgName: 'locked_by_name' }),\n lockedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'locked_at',\n }),\n expiresAt: column.timestamp({\n notNull: true,\n withTimezone: true,\n pgName: 'expires_at',\n }),\n },\n unique: [{ on: ['entityType', 'entityId', 'locale'] }],\n})\n\n/** Backwards-compatible export — existing imports of the raw pgTable still work. */\nexport const toolkitContentLocks = contentLocksTable.table\n"],"mappings":"0DAYA,MAAa,EAAoB,EAAY,CAC3C,KAAM,wBACN,QAAS,CACP,GAAI,EAAO,KAAK,CAAE,WAAY,GAAM,cAAe,EAAK,CAAC,EACzD,WAAY,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,OAAQ,aAAc,CAAC,EAChF,SAAU,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,OAAQ,WAAY,CAAC,EAC5E,OAAQ,EAAO,QAAQ,CAAE,OAAQ,GAAI,QAAS,GAAM,QAAS,GAAI,CAAC,EAClE,SAAU,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,OAAQ,WAAY,CAAC,EAC5E,aAAc,EAAO,QAAQ,CAAE,OAAQ,IAAK,OAAQ,gBAAiB,CAAC,EACtE,SAAU,EAAO,UAAU,CACzB,QAAS,GACT,WAAY,GACZ,aAAc,GACd,OAAQ,WACV,CAAC,EACD,UAAW,EAAO,UAAU,CAC1B,QAAS,GACT,aAAc,GACd,OAAQ,YACV,CAAC,CACH,EACA,OAAQ,CAAC,CAAE,GAAI,CAAC,aAAc,WAAY,QAAQ,CAAE,CAAC,CACvD,CAAC,EAGY,EAAsB,EAAkB"}
|