@murumets-ee/content 0.34.1 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/client.d.mts +2 -2
  2. package/dist/client.mjs +1 -1
  3. package/dist/client.mjs.map +1 -1
  4. package/dist/entities/index.d.mts +1 -1
  5. package/dist/{index-kQaEm4Ft.d.mts → index-D8nN7BFd.d.mts} +22 -3
  6. package/dist/index-D8nN7BFd.d.mts.map +1 -0
  7. package/dist/index.d.mts +4 -3
  8. package/dist/index.mjs +1 -1
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/locale-utils-CuIBKeaz.mjs +2 -0
  11. package/dist/locale-utils-CuIBKeaz.mjs.map +1 -0
  12. package/dist/lock-service.d.mts +1 -2
  13. package/dist/lock-service.d.mts.map +1 -1
  14. package/dist/plugin-IW9SM9iO.mjs +2 -0
  15. package/dist/plugin-IW9SM9iO.mjs.map +1 -0
  16. package/dist/plugin.d.mts +18 -5
  17. package/dist/plugin.d.mts.map +1 -1
  18. package/dist/plugin.mjs +1 -1
  19. package/dist/query-client.d.mts +0 -1
  20. package/dist/query-client.d.mts.map +1 -1
  21. package/dist/query-client.mjs +1 -1
  22. package/dist/routing-strategy-DGx6Rq6F.mjs +2 -0
  23. package/dist/routing-strategy-DGx6Rq6F.mjs.map +1 -0
  24. package/dist/routing-strategy-TPSzrapD.d.mts +121 -0
  25. package/dist/routing-strategy-TPSzrapD.d.mts.map +1 -0
  26. package/dist/{types-yRLOOCOn.d.mts → types-CUw2Pf0Y.d.mts} +36 -2
  27. package/dist/types-CUw2Pf0Y.d.mts.map +1 -0
  28. package/package.json +7 -6
  29. package/dist/errors-bZAGPYhp.mjs +0 -2
  30. package/dist/errors-bZAGPYhp.mjs.map +0 -1
  31. package/dist/index-kQaEm4Ft.d.mts.map +0 -1
  32. package/dist/locale-utils-w0WO6J_P.mjs +0 -2
  33. package/dist/locale-utils-w0WO6J_P.mjs.map +0 -1
  34. package/dist/plugin-CG2tNeoG.mjs +0 -2
  35. package/dist/plugin-CG2tNeoG.mjs.map +0 -1
  36. package/dist/types-yRLOOCOn.d.mts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/capabilities.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 * (`composeMultiScopeDocument` in `@murumets-ee/blocks/server`) reads this\n * field and calls `applyOverrides()` AFTER the multi-scope tree has been\n * assembled.\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 `composeMultiScopeDocument`'s\n // `readOverridesMap` 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: pages overriding more than ~50 scoped blocks is a\n * smell (consider a `route_layout` instead).\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 _overrides: field.json({\n default: () => ({}),\n internal: 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 * 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"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/capabilities.ts","../src/locale-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 * (`composeMultiScopeDocument` in `@murumets-ee/blocks/server`) reads this\n * field and calls `applyOverrides()` AFTER the multi-scope tree has been\n * assembled.\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 `composeMultiScopeDocument`'s\n // `readOverridesMap` 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: pages overriding more than ~50 scoped blocks is a\n * smell (consider a `route_layout` instead).\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 _overrides: field.json({\n default: () => ({}),\n internal: 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 * 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 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 { mergeConfigOverrides, type ToolkitApp } 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/** Elevated resolver for the app-wide registry read (the roles-read analog). */\nconst SYSTEM_CONTEXT = () => ({\n user: { id: 'system', groups: ['admin'] },\n checker: () => true,\n})\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"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{r as e,u as t}from"./routing-strategy-DGx6Rq6F.mjs";import"./plugin-IW9SM9iO.mjs";import{getContext as n}from"@murumets-ee/core";function r(r){if(r===`_`)return;let i=n()?.availableLocales??e().locales.map(e=>e.code);for(let e of i)if(e===r)return;throw new t(r,[...i])}export{r as t};
2
+ //# sourceMappingURL=locale-utils-CuIBKeaz.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-utils-CuIBKeaz.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'\nimport { InvalidLocaleError } from './errors.js'\nimport { getContentConfig } from './plugin.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":"wIAmDA,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,6 +1,5 @@
1
- import { a as LockInfo } from "./types-yRLOOCOn.mjs";
1
+ import { c as LockInfo } from "./types-CUw2Pf0Y.mjs";
2
2
  import { ToolkitApp } from "@murumets-ee/core";
3
-
4
3
  //#region src/lock-service.d.ts
5
4
  interface LockServiceConfig {
6
5
  app: ToolkitApp;
@@ -1 +1 @@
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
+ {"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"}
@@ -0,0 +1,2 @@
1
+ import{a as e,i as t,s as n,u as r}from"./routing-strategy-DGx6Rq6F.mjs";import{n as i}from"./locks-5779ugSW.mjs";import{definePlugin as a,getContext as o}from"@murumets-ee/core";import{column as s,defineTable as c,schemaRegistry as l}from"@murumets-ee/db";import{eq as u,sql as d}from"drizzle-orm";const f=Symbol.for(`@murumets-ee/content:content-tables-registry`);function p(){let e=globalThis,t=e[f];return t||(t=new Map,e[f]=t),t}function m(e,t){let n=p(),r=n.get(e)??{};n.set(e,{...r,...t})}function h(e){return p().get(e)??{}}const g=Symbol.for(`@murumets-ee/content:locale-status-writer`);async function _(e,t,n,r){let i=r===`published`?new Date:null;await e.upsert({entityId:t,locale:n,status:r,publishedAt:i},{target:[`entityId`,`locale`],set:{status:r,publishedAt:i}})}function v(e){globalThis[g]=(t,n)=>{let{localeStatus:i}=h(t.name);if(i)return async(t,a,s)=>{let c=o()?.availableLocales??e;if(!c.includes(a))throw new r(a,[...c]);await _(i.makeClient(n),t,a,s)}}}function y(){return{async writeDraft({documentType:e,documentId:t,locale:n,dataPatch:r,user:i,tx:a}){let{drafts:o}=h(e);if(!o)return!1;let s=o.makeClient(a),c={...(await s.findOne({entityId:t,locale:n}))?.data??{},...r},l=new Date;return await s.upsert({entityId:t,locale:n,data:c,createdBy:i.id,createdByName:i.name??null,createdAt:l,updatedAt:l},{target:[`entityId`,`locale`],set:{data:c,updatedAt:l}}),!0},async hasAnyDraft({documentType:e,documentId:t,db:n}){let{drafts:r}=h(e);return r?r.makeClient(n).exists({entityId:t}):!1},async hasAnyLocaleActivity({documentType:e,documentId:t,db:n}){let{localeStatus:r}=h(e);if(r&&await r.makeClient(n).exists({entityId:t}))return!0;let i=l.get(`${e}_translations`);if(i){let e=i.entityId;if(e!==void 0&&(await n.select({one:d`1`}).from(i).where(u(e,t)).limit(1)).length>0)return!0}return!1}}}function b(e){return c({name:`${e.name}_drafts`,columns:{id:s.uuid({primaryKey:!0,defaultRandom:!0}),entityId:s.uuid({notNull:!0,pgName:`entity_id`}),locale:s.varchar({length:10,notNull:!0,default:`_`}),data:s.jsonb({notNull:!0}),createdBy:s.varchar({length:255,notNull:!0,pgName:`created_by`}),createdByName:s.varchar({length:255,pgName:`created_by_name`}),createdAt:s.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:s.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},unique:[{on:[`entityId`,`locale`]}]})}function x(e){return c({name:`${e.name}_locale_status`,columns:{id:s.uuid({primaryKey:!0,defaultRandom:!0}),entityId:s.uuid({notNull:!0,pgName:`entity_id`}),locale:s.varchar({length:10,notNull:!0}),status:s.varchar({length:20,notNull:!0,default:`draft`}),publishedAt:s.timestamp({withTimezone:!0,pgName:`published_at`})},unique:[{on:[`entityId`,`locale`]}]})}function S(e){return c({name:`${e.name}_versions`,columns:{id:s.uuid({primaryKey:!0,defaultRandom:!0}),entityId:s.uuid({notNull:!0,pgName:`entity_id`}),version:s.integer({notNull:!0}),locale:s.varchar({length:10,notNull:!0,default:`_`}),data:s.jsonb({notNull:!0}),delta:s.jsonb(),status:s.varchar({length:20}),createdBy:s.varchar({length:255,pgName:`created_by`}),createdByName:s.varchar({length:255,pgName:`created_by_name`}),createdAt:s.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),isAutosave:s.boolean({notNull:!0,default:!1,pgName:`is_autosave`})},unique:[{on:[`entityId`,`version`,`locale`]}]})}function C(r){if(!r.locales||r.locales.length===0)throw Error(`content() requires at least one locale`);if(!r.defaultLocale)throw Error(`content() requires a defaultLocale`);if(!r.locales.some(e=>e.code===r.defaultLocale))throw Error(`defaultLocale "${r.defaultLocale}" not found in locales: [${r.locales.map(e=>e.code).join(`, `)}]`);let o=r.autoTrash??!0,s=o===!1?void 0:{publishable:!0,...typeof o==`object`&&o.exclude?{exclude:o.exclude}:{}};return a({name:`@murumets-ee/content`,contributes:{"blocks.documentDraftStore":y(),...s?{"trash.autoEnable":s}:{}},shared:{settings:[n]},server:{entities:[e],tables:{toolkitContentLocks:i},init:async e=>{t(r);let n=0,i=0,a=0;for(let[t,r]of e.entities){if(r.behaviors?.some(e=>e.name===`versionable`)){let e=`${t}_versions`;if(!l.has(e)){let n=S(r);l.register(e,n.table),m(t,{versions:n})}n++}let e=r.behaviors?.some(e=>e.name===`publishable`),o=r.allFields,s=Object.values(o).some(e=>e.translatable);if(e&&s){let e=`${t}_locale_status`;if(!l.has(e)){let n=x(r);l.register(e,n.table),m(t,{localeStatus:n})}i++}if(e){let e=`${t}_drafts`;if(!l.has(e)){let n=b(r);l.register(e,n.table),m(t,{drafts:n})}a++}}v(r.locales.map(e=>e.code)),e.logger.info({locales:r.locales.map(e=>e.code),defaultLocale:r.defaultLocale,versionableEntities:n,localeStatusEntities:i,draftEntities:a},`Content plugin initialized`)}}})}export{_ as n,h as r,C as t};
2
+ //# sourceMappingURL=plugin-IW9SM9iO.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-IW9SM9iO.mjs","names":["cols"],"sources":["../src/schema/registry.ts","../src/locale-status-slot.ts","../src/document-draft-store.ts","../src/schema/drafts.ts","../src/schema/locale-status.ts","../src/schema/versions.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Per-entity content table registry.\n *\n * Maps an entity name to its content infrastructure tables — drafts,\n * versions, locale_status. Each entry stores the `DefinedTable` (from\n * `defineTable`), which exposes a typed `.makeClient(db)` for CRUD via\n * `TableClient` and a raw `.table` for legacy callers.\n *\n * The content plugin populates this registry at `init` time; ContentClient\n * reads from it lazily on first use. Tables that don't apply to an entity\n * (non-publishable entities have no drafts, etc.) are simply absent.\n */\n\nimport type { DraftsTable } from './drafts.js'\nimport type { LocaleStatusTable } from './locale-status.js'\nimport type { VersionsTable } from './versions.js'\n\n/**\n * The full set of optional content infra tables attached to an entity.\n * Every field is optional — entities without the relevant behavior don't\n * get the corresponding table.\n */\nexport interface ContentTables {\n /** `{entity}_drafts` — present when the entity has `publishable()`. */\n drafts?: DraftsTable\n /** `{entity}_versions` — present when the entity has `versionable()`. */\n versions?: VersionsTable\n /** `{entity}_locale_status` — present when the entity is publishable + has translatable fields. */\n localeStatus?: LocaleStatusTable\n}\n\n/**\n * The registry map lives on `globalThis` keyed by `Symbol.for(...)`, NOT as a\n * module-level binding — matching core's `PluginContributionsRegistry`\n * pattern. A module-level `Map` here is the exact Turbopack-dev footgun\n * CLAUDE.md documents (the createdBy/updatedBy bug class): a lazily-compiled\n * route chunk can resolve this module to a DIFFERENT instance than the boot\n * path that called `registerContentTables`, so `getContentTables()` returns\n * `{}` and callers silently take their no-table fallbacks. Observed in the\n * wild 2026-06-11 (F031 #1): the blocks op-commit route saw an empty registry,\n * `writeDraft` fell back to live-row writes, and Publish 500'd with \"No draft\n * found\" — flaky per-recompile, dev-only. `Symbol.for` is process-global, so\n * every duplicate module instance reads the same map.\n */\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/content:content-tables-registry')\n\ninterface GlobalWithRegistry {\n [REGISTRY_KEY]?: Map<string, ContentTables>\n}\n\nfunction registryMap(): Map<string, ContentTables> {\n const g = globalThis as GlobalWithRegistry\n let map = g[REGISTRY_KEY]\n if (!map) {\n map = new Map<string, ContentTables>()\n g[REGISTRY_KEY] = map\n }\n return map\n}\n\n/**\n * Register content tables for an entity. Called from `content().init()`.\n *\n * Subsequent calls for the same entity name MERGE the provided patch into\n * the existing entry. This lets the plugin add tables one at a time (drafts,\n * then versions, then locale_status) without the caller needing to build the\n * whole object up front.\n */\nexport function registerContentTables(entityName: string, patch: ContentTables): void {\n const tables = registryMap()\n const existing = tables.get(entityName) ?? {}\n tables.set(entityName, { ...existing, ...patch })\n}\n\n/**\n * Get the content tables for an entity. Returns an empty object (rather\n * than undefined) when the entity has no registered tables — that\n * simplifies the common ContentClient lookup idiom:\n *\n * const { drafts } = getContentTables(entity.name)\n * if (!drafts) return null\n */\nexport function getContentTables(entityName: string): ContentTables {\n return registryMap().get(entityName) ?? {}\n}\n\n/**\n * Clear the registry. **Test-only.** Throws in production.\n */\nexport function clearContentTables(): void {\n if (process.env.NODE_ENV === 'production') {\n throw new Error(\n 'clearContentTables() is not allowed in production — this is a test-only utility.',\n )\n }\n registryMap().clear()\n}\n","/**\n * Process-global slot exposing the content plugin's per-locale publish\n * status writer to `AdminClient.updateForLocale` (SD021 — the\n * locale-write choke point).\n *\n * `@murumets-ee/core`'s `createAdminClient` reads the slot by symbol\n * identity when constructing a client, so EVERY framework-built\n * AdminClient routes non-default-locale `status` writes into\n * `<entity>_locale_status` automatically — future writers (import\n * pipelines, AI integration, workflow actions) cannot forget the\n * routing. Same `globalThis` + `Symbol.for` shape as queue's\n * `enqueue-on-commit-slot.ts`: survives HMR and avoids a dependency\n * edge from core onto content.\n *\n * NOTE: this module must stay free of `import 'server-only'` — it is in\n * the content plugin's `init` load path, which the `lumi` CLI loads in\n * plain Node (see memory: server-only in the CLI load path breaks every\n * command and only scaffold-e2e catches it).\n */\n\nimport { getContext } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport type { LocaleStatusWriter } from '@murumets-ee/entity/admin'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { InvalidLocaleError } from './errors.js'\nimport type { LocaleStatusTable } from './schema/locale-status.js'\nimport { getContentTables } from './schema/registry.js'\n\nconst LOCALE_STATUS_WRITER_KEY = Symbol.for('@murumets-ee/content:locale-status-writer')\n\n/** The typed TableClient for a `{entity}_locale_status` table. */\ntype LocaleStatusClient = ReturnType<LocaleStatusTable['makeClient']>\n\n/**\n * Upsert one non-default locale's status row. The single implementation\n * shared by `setLocaleStatus` (content's own API) and the slot writer\n * below — keeping the `publishedAt` semantics in one place.\n *\n * `client` is the locale-status TableClient; callers resolve it from\n * `getContentTables(entity).localeStatus` (capability-gated).\n */\nexport async function upsertLocaleStatusRow(\n client: LocaleStatusClient,\n entityId: string,\n locale: string,\n status: 'draft' | 'published',\n): Promise<void> {\n const publishedAt = status === 'published' ? new Date() : null\n await client.upsert(\n { entityId, locale, status, publishedAt },\n { target: ['entityId', 'locale'], set: { status, publishedAt } },\n )\n}\n\n/**\n * Write the locale-status writer factory into the slot. Called from the\n * content plugin's `init` AFTER the per-entity `<entity>_locale_status`\n * tables are registered (the factory consults that registry per entity).\n *\n * `localeCodes` is the plugin's configured locale set (the shipped `content()`\n * defaults), passed in by init rather than read via `getContentConfig()` —\n * importing `locale-utils`/`plugin` here would close an import cycle (plugin →\n * slot → … → plugin; `verify:cycles` catches it). It is the **static fallback**\n * only; the write-time whitelist prefers the request-scoped effective set (below).\n *\n * The factory returns `undefined` for entities without a locale-status\n * table — `updateForLocale` then falls back to the base `status` column,\n * which is correct: for non-translatable publishables the default locale\n * is the only locale.\n */\nexport function setActiveLocaleStatusWriterFactory(localeCodes: readonly string[]): void {\n const factory = (entity: Entity, db: PostgresJsDatabase): LocaleStatusWriter | undefined => {\n const { localeStatus } = getContentTables(entity.name)\n if (!localeStatus) return undefined\n return async (entityId, locale, status) => {\n // Same runtime-aware whitelist as locale-utils' validateLocale (SD006), sans\n // the '_' sentinel (updateForLocale never sends it here): prefer the\n // request-scoped effective enabled codes so a locale added/enabled at runtime\n // can have translatable content published in it with no rebuild, falling back\n // to the init-time static set outside a request (CLI / build / tests).\n // Unvalidated locales must not mint locale_status rows no reader will ever merge.\n const allowed = getContext()?.availableLocales ?? localeCodes\n if (!allowed.includes(locale)) {\n throw new InvalidLocaleError(locale, [...allowed])\n }\n // The caller (core's createAdminClient) passes the SAME db handle the\n // AdminClient writes through — a client built against an explicit app\n // (tests, multi-app setups) must not write its locale-status rows\n // through the singleton app's connection.\n const client = localeStatus.makeClient(db)\n await upsertLocaleStatusRow(client, entityId, locale, status)\n }\n }\n ;(globalThis as Record<symbol, unknown>)[LOCALE_STATUS_WRITER_KEY] = factory\n}\n","/**\n * `DocumentDraftStore` implementation contributed to the block editor's\n * `blocks.documentDraftStore` slot (SD015).\n *\n * The block editor's op-commit route (in `@murumets-ee/blocks`) materializes a\n * `withOpLog()` document's working tree by calling this store inside its\n * commit transaction — so block edits land in the per-locale `{entity}_drafts`\n * buffer, never the live/public row. A later \"Publish\" (`publishDraft`)\n * promotes the draft to the live row + snapshots a version.\n *\n * blocks owns the slot type (imported `type`-only below — that import also\n * pulls the `PluginContributionSlots` augmentation into this compilation);\n * content owns the draft tables. No runtime dependency between the packages.\n *\n * NB: no `import 'server-only'` here. This module is imported by\n * `content/plugin.ts` at construct time (for the `contributes` slot), and the\n * content plugin must stay loadable in a plain-Node context (the `lumi migrate`\n * CLI loads `lumi.config.ts` without the `react-server` resolution condition,\n * where `server-only` throws). The store is only ever *called* server-side (by\n * the op-commit committer) and is never reachable from a client bundle.\n */\n\nimport type { DocumentDraftStore } from '@murumets-ee/blocks/server'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { eq, sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { getContentTables } from './schema/registry.js'\n\n/**\n * Build the draft store. Stateless — resolves the per-entity drafts table from\n * the schema registry at call time, so it composes with any app's entity set.\n */\nexport function createDocumentDraftStore(): DocumentDraftStore {\n return {\n async writeDraft({ documentType, documentId, locale, dataPatch, user, tx }) {\n const { drafts } = getContentTables(documentType)\n // No drafts table → the document type isn't publishable; signal the\n // caller to fall back to writing the live row.\n if (!drafts) return false\n\n // Bind the drafts TableClient to the committer's tx so the upsert joins\n // the same SERIALIZABLE transaction as the editor_op insert.\n const client = drafts.makeClient(tx)\n const existing = await client.findOne({ entityId: documentId, locale })\n // Merge onto any existing draft payload so a concurrent form-field draft\n // (saved via saveDraft) isn't clobbered by a block edit, and vice versa.\n const baseData = (existing?.data as Record<string, unknown> | undefined) ?? {}\n const data = { ...baseData, ...dataPatch }\n const now = new Date()\n\n await client.upsert(\n {\n entityId: documentId,\n locale,\n data,\n createdBy: user.id,\n createdByName: user.name ?? null,\n createdAt: now,\n updatedAt: now,\n },\n {\n target: ['entityId', 'locale'],\n // On conflict, only touch the mutable fields — preserve createdBy/createdAt.\n set: { data, updatedAt: now },\n },\n )\n return true\n },\n\n async hasAnyDraft({ documentType, documentId, db }) {\n const { drafts } = getContentTables(documentType)\n if (!drafts) return false\n // Any-locale existence probe — the orphan-draft GC (blocks PR 25)\n // spares a row when ANY locale still carries draft work.\n const client = drafts.makeClient(db)\n return client.exists({ entityId: documentId })\n },\n\n async hasAnyLocaleActivity({ documentType, documentId, db }) {\n // Per-locale publish status (publishDraft on a non-default locale\n // writes HERE, never the base row's status — pr-review H2).\n const { localeStatus } = getContentTables(documentType)\n if (localeStatus) {\n const client = localeStatus.makeClient(db)\n if (await client.exists({ entityId: documentId })) return true\n }\n // Translation rows (saveTranslation writes them directly, no hooks —\n // the base row's updatedAt never moves). Translation tables are\n // generated in @murumets-ee/entity; accessed via schemaRegistry +\n // direct Drizzle, same idiom as translation-status.ts.\n const translationTable = schemaRegistry.get(`${documentType}_translations`)\n if (translationTable) {\n const cols = translationTable as Record<string, PgColumn | undefined>\n const entityIdCol = cols.entityId\n if (entityIdCol !== undefined) {\n const rows = await db\n .select({ one: sql`1` })\n .from(translationTable)\n .where(eq(entityIdCol, documentId))\n .limit(1)\n if (rows.length > 0) return true\n }\n }\n return false\n },\n }\n}\n","/**\n * Draft overlay table schema generator\n *\n * Generates a {entity}_drafts table for entities with the publishable() behavior.\n * Stores a mutable work-in-progress snapshot that non-publishers edit instead of\n * writing directly to the live published row.\n *\n * One draft per entity+locale. Drafts are upserted, discarded, or promoted to live.\n *\n * Uses `defineTable` so ContentClient can operate on drafts through a typed\n * TableClient (see registry.ts). The migration pipeline uses the\n * entity-package generator (which emits FK references) — this one produces a\n * runtime-only table shape for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a publishable entity's drafts table.\n *\n * Schema:\n * {entity}_drafts (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * locale varchar(10) NOT NULL DEFAULT '_',\n * data jsonb NOT NULL, -- full entity snapshot\n * created_by varchar(255) NOT NULL, -- user ID (varchar, NOT uuid!)\n * created_by_name varchar(255),\n * created_at timestamptz NOT NULL DEFAULT now(),\n * updated_at timestamptz NOT NULL DEFAULT now(),\n * UNIQUE(entity_id, locale)\n * )\n */\nexport function generateDraftsTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_drafts`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n data: column.jsonb<Record<string, unknown>, true>({ notNull: true }),\n createdBy: column.varchar({ length: 255, notNull: true, pgName: 'created_by' }),\n createdByName: column.varchar({ length: 255, pgName: 'created_by_name' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n unique: [{ on: ['entityId', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type DraftsTable = ReturnType<typeof generateDraftsTable>\n","/**\n * Per-locale publish status table schema generator\n *\n * Generates a {entity}_locale_status table for entities that have both\n * publishable() behavior AND translatable fields. This allows different\n * locales to have independent publish statuses.\n *\n * Fallback rule: no row for a locale = inherit base entity status.\n *\n * Uses `defineTable` so ContentClient can operate on rows through a typed\n * TableClient. The migration pipeline uses the entity-package generator\n * (which emits FK references); this one produces a runtime-only table shape\n * for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a publishable + translatable entity's\n * locale status table.\n *\n * Schema:\n * {entity}_locale_status (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * locale varchar(10) NOT NULL,\n * status varchar(20) NOT NULL DEFAULT 'draft',\n * published_at timestamptz,\n * UNIQUE(entity_id, locale)\n * )\n */\nexport function generateLocaleStatusTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_locale_status`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true }),\n status: column.varchar({ length: 20, notNull: true, default: 'draft' }),\n publishedAt: column.timestamp({ withTimezone: true, pgName: 'published_at' }),\n },\n unique: [{ on: ['entityId', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type LocaleStatusTable = ReturnType<typeof generateLocaleStatusTable>\n","/**\n * Version history table schema generator\n *\n * Generates a {entity}_versions table for entities with the versionable() behavior.\n * Each version stores a full snapshot + delta (changed fields only).\n *\n * Uses `defineTable` so ContentClient can operate on versions through a typed\n * TableClient. The migration pipeline uses the entity-package generator\n * (which emits FK references) — this one produces a runtime-only table shape\n * for read/write via TableClient.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { Entity } from '@murumets-ee/entity'\n\n/**\n * Generate a `DefinedTable` for a versionable entity's versions table.\n *\n * Schema:\n * {entity}_versions (\n * id uuid PK,\n * entity_id uuid NOT NULL,\n * version integer NOT NULL,\n * locale varchar(10) NOT NULL DEFAULT '_', -- locale this snapshot belongs to\n * data jsonb NOT NULL, -- full snapshot (locale-merged view)\n * delta jsonb, -- only changed fields\n * status varchar(20), -- 'draft' | 'published' at time of save\n * created_by varchar(255),\n * created_by_name varchar(255),\n * created_at timestamptz NOT NULL DEFAULT now(),\n * is_autosave boolean NOT NULL DEFAULT false,\n * UNIQUE(entity_id, version, locale)\n * )\n *\n * Each locale has its own version sequence. The default locale uses '_'\n * as the locale value (distinguishable from actual locale codes).\n */\nexport function generateVersionsTable(entity: Entity) {\n return defineTable({\n name: `${entity.name}_versions`,\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityId: column.uuid({ notNull: true, pgName: 'entity_id' }),\n version: column.integer({ notNull: true }),\n locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n data: column.jsonb<Record<string, unknown>, true>({ notNull: true }),\n delta: column.jsonb<Record<string, unknown>>(),\n status: column.varchar({ length: 20 }),\n createdBy: column.varchar({ length: 255, pgName: 'created_by' }),\n createdByName: column.varchar({ length: 255, pgName: 'created_by_name' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n isAutosave: column.boolean({ notNull: true, default: false, pgName: 'is_autosave' }),\n },\n unique: [{ on: ['entityId', 'version', 'locale'] }],\n })\n}\n\n/** Public type export — the full DefinedTable returned by the generator. */\nexport type VersionsTable = ReturnType<typeof generateVersionsTable>\n","/**\n * Content plugin — registers locale configuration and version schemas.\n *\n * Usage:\n * import { content } from '@murumets-ee/content/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * content({\n * locales: [\n * { code: 'en', label: 'English' },\n * { code: 'de', label: 'German' },\n * ],\n * defaultLocale: 'en',\n * }),\n * ],\n * })\n */\n\nimport { definePlugin, type TrashAutoEnablePolicy } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { setContentConfig } from './content-config-store.js'\nimport { createDocumentDraftStore } from './document-draft-store.js'\nimport { i18nSettings } from './i18n-settings.js'\nimport { Locale } from './locale-entity.js'\n\n// Re-export the config accessor from the leaf store so the `@murumets-ee/content/plugin`\n// subpath API is unchanged for existing consumers (admin-context, entity pages).\nexport { getContentConfig } from './content-config-store.js'\n// Re-export the i18n registry primitives from this jiti-safe subpath (all sources are\n// leaf: locale-entity → entity only, i18n-settings → settings/define, routing-strategy →\n// types only), so the admin-ui Languages editor route can import them WITHOUT dragging\n// content's `server-only` main entry through the config-load / bundle graph.\nexport { ROUTING_STRATEGIES, i18nSettings } from './i18n-settings.js'\nexport { Locale } from './locale-entity.js'\nexport {\n localePrefixForStrategy,\n prefixDefaultLocaleForStrategy,\n} from './routing-strategy.js'\nexport type { I18nConfig, LocaleDirection, LocaleEntry, RoutingStrategy } from './types.js'\nimport { setActiveLocaleStatusWriterFactory } from './locale-status-slot.js'\nimport { generateDraftsTable } from './schema/drafts.js'\nimport { generateLocaleStatusTable } from './schema/locale-status.js'\nimport { toolkitContentLocks } from './schema/locks.js'\nimport { registerContentTables } from './schema/registry.js'\nimport { generateVersionsTable } from './schema/versions.js'\nimport type { ContentConfig } from './types.js'\n\n/**\n * Content plugin factory.\n */\nexport function content(config: ContentConfig) {\n // Validate config\n if (!config.locales || config.locales.length === 0) {\n throw new Error('content() requires at least one locale')\n }\n\n if (!config.defaultLocale) {\n throw new Error('content() requires a defaultLocale')\n }\n\n const defaultExists = config.locales.some((l) => l.code === config.defaultLocale)\n if (!defaultExists) {\n throw new Error(\n `defaultLocale \"${config.defaultLocale}\" not found in locales: [${config.locales.map((l) => l.code).join(', ')}]`,\n )\n }\n\n // plan/trash SD002 — declare the auto-enable policy for the framework to\n // apply at boot (createApp injects `trashable()` into publishable entities\n // BEFORE schema-gen). Declarative, so it can't be forgotten and carries no\n // plugin-array-order hazard. `autoTrash` defaults ON.\n const autoTrash = config.autoTrash ?? true\n const trashPolicy: TrashAutoEnablePolicy | undefined =\n autoTrash === false\n ? undefined\n : {\n publishable: true,\n ...(typeof autoTrash === 'object' && autoTrash.exclude\n ? { exclude: autoTrash.exclude }\n : {}),\n }\n\n return definePlugin({\n name: '@murumets-ee/content',\n // SD015 — contribute the draft store the block editor's op-commit route\n // writes through. blocks owns the slot; content owns the {entity}_drafts\n // tables. Stateless; resolves the per-entity drafts table at call time.\n contributes: {\n 'blocks.documentDraftStore': createDocumentDraftStore(),\n // SD002 — only contribute when auto-trash is enabled (omitted → no policy\n // → createApp's applyTrashAutoEnable is a no-op for content).\n ...(trashPolicy ? { 'trash.autoEnable': trashPolicy } : {}),\n },\n // The `i18n` singletons (defaultLocale, routingStrategy) — a simple-config\n // namespace that auto-registers as a config type (D006/D009/SD006) and rides\n // export/import for free. Edited via the Languages editor (`hideFromMenu`).\n shared: {\n settings: [i18nSettings],\n },\n server: {\n // `Locale` — the content-locale registry config entity (D009/SD006). Declared\n // statically so the framework registers its table at merge (before init) and\n // auto-derives it into the ConfigTypeRegistry.\n entities: [Locale],\n // The shared locks table is registered declaratively via the\n // framework's merge step (#469). The per-entity tables below\n // (versions / layout / drafts / locale_status) are generated\n // dynamically from `app.entities` and stay imperatively\n // registered — they're not known at plugin-construct time.\n tables: { toolkitContentLocks },\n init: async (app) => {\n // Store config globally (leaf store — no schema imports)\n setContentConfig(config)\n\n let versionableCount = 0\n let localeStatusCount = 0\n let draftsCount = 0\n\n for (const [name, entity] of app.entities) {\n // Register version tables for versionable entities\n const hasVersionable = entity.behaviors?.some(\n (b: { name: string }) => b.name === 'versionable',\n )\n if (hasVersionable) {\n const versionsTableName = `${name}_versions`\n if (!schemaRegistry.has(versionsTableName)) {\n const versions = generateVersionsTable(entity)\n schemaRegistry.register(versionsTableName, versions.table)\n registerContentTables(name, { versions })\n }\n versionableCount++\n }\n\n // Register locale_status tables for publishable + translatable entities\n const isPublishable = entity.behaviors?.some(\n (b: { name: string }) => b.name === 'publishable',\n )\n const allFields = entity.allFields as Record<string, { translatable?: boolean }>\n const hasTranslatable = Object.values(allFields).some((f) => f.translatable)\n if (isPublishable && hasTranslatable) {\n const statusTableName = `${name}_locale_status`\n if (!schemaRegistry.has(statusTableName)) {\n const localeStatus = generateLocaleStatusTable(entity)\n schemaRegistry.register(statusTableName, localeStatus.table)\n registerContentTables(name, { localeStatus })\n }\n localeStatusCount++\n }\n\n // Register drafts table for publishable entities\n if (isPublishable) {\n const draftsTableName = `${name}_drafts`\n if (!schemaRegistry.has(draftsTableName)) {\n const drafts = generateDraftsTable(entity)\n schemaRegistry.register(draftsTableName, drafts.table)\n registerContentTables(name, { drafts })\n }\n draftsCount++\n }\n }\n\n // Expose the per-locale status writer to AdminClient.updateForLocale\n // (SD021). Must run AFTER the locale_status registration loop above —\n // the factory consults that registry per entity. This is capability\n // injection via a globalThis slot (queue's enqueue-on-commit\n // precedent), not contribution registration — no merge-step involved.\n setActiveLocaleStatusWriterFactory(config.locales.map((l) => l.code))\n\n app.logger.info(\n {\n locales: config.locales.map((l) => l.code),\n defaultLocale: config.defaultLocale,\n versionableEntities: versionableCount,\n localeStatusEntities: localeStatusCount,\n draftEntities: draftsCount,\n },\n 'Content plugin initialized',\n )\n },\n },\n })\n}\n"],"mappings":"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"}
package/dist/plugin.d.mts CHANGED
@@ -1,17 +1,30 @@
1
- import { t as ContentConfig } from "./types-yRLOOCOn.mjs";
1
+ import { a as LocaleDirection, l as RoutingStrategy, o as LocaleEntry, r as I18nConfig, t as ContentConfig } from "./types-CUw2Pf0Y.mjs";
2
+ import { a as i18nSettings, i as ROUTING_STRATEGIES, n as prefixDefaultLocaleForStrategy, r as Locale, t as localePrefixForStrategy } from "./routing-strategy-TPSzrapD.mjs";
2
3
 
3
- //#region src/plugin.d.ts
4
+ //#region src/content-config-store.d.ts
4
5
  /**
5
- * Get the content configuration. Throws if plugin not initialized.
6
+ * Get the content configuration. Throws if the plugin hasn't initialized.
6
7
  */
7
8
  declare function getContentConfig(): ContentConfig;
9
+ //#endregion
10
+ //#region src/plugin.d.ts
8
11
  /**
9
12
  * Content plugin factory.
10
13
  */
11
14
  declare function content(config: ContentConfig): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall" | "requires" | "contributes"> & {
12
15
  readonly name: "@murumets-ee/content";
13
- readonly shared?: undefined;
16
+ readonly shared?: {
17
+ readonly settings: readonly [import("@murumets-ee/settings/define").SettingsDefinition<{
18
+ readonly defaultLocale: import("@murumets-ee/settings/define").TextSettingConfig & {
19
+ readonly default: "";
20
+ };
21
+ readonly routingStrategy: import("@murumets-ee/settings/define").SelectSettingConfig<readonly ["prefix-all", "no-prefix-default", "cookie"]> & {
22
+ readonly options: readonly ["prefix-all", "no-prefix-default", "cookie"];
23
+ readonly default: "no-prefix-default";
24
+ };
25
+ }, "i18n">];
26
+ };
14
27
  };
15
28
  //#endregion
16
- export { content, getContentConfig };
29
+ export { type I18nConfig, Locale, type LocaleDirection, type LocaleEntry, ROUTING_STRATEGIES, type RoutingStrategy, content, getContentConfig, i18nSettings, localePrefixForStrategy, prefixDefaultLocaleForStrategy };
17
30
  //# sourceMappingURL=plugin.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;;;iBAiDgB,gBAAA,CAAA,GAAoB,aAAa;;;;iBAajC,OAAA,CAAQ,MAAA,EAAQ,aAAA,GAAa,IAAA,6BAAA,MAAA;EAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/content-config-store.ts","../src/plugin.ts"],"mappings":";;;;;;;iBA0CgB,gBAAA,CAAA,GAAoB,aAAa;;;;;;iBCSjC,OAAA,CAAQ,MAAA,EAAQ,aAAA,GAAa,IAAA,6BAAA,MAAA;EAAA"}
package/dist/plugin.mjs CHANGED
@@ -1 +1 @@
1
- import{n as e,t}from"./plugin-CG2tNeoG.mjs";export{t as content,e as getContentConfig};
1
+ import{a as e,n as t,o as n,r,s as i,t as a}from"./routing-strategy-DGx6Rq6F.mjs";import{t as o}from"./plugin-IW9SM9iO.mjs";export{e as Locale,n as ROUTING_STRATEGIES,o as content,r as getContentConfig,i as i18nSettings,a as localePrefixForStrategy,t as prefixDefaultLocaleForStrategy};
@@ -1,6 +1,5 @@
1
1
  import { Entity } from "@murumets-ee/entity";
2
2
  import { CountOptions, FindByIdOptions, FindManyOptions, QueryClient } from "@murumets-ee/entity/query";
3
-
4
3
  //#region src/query-client.d.ts
5
4
  interface ContentQueryClientConfig {
6
5
  query: QueryClient;
@@ -1 +1 @@
1
- {"version":3,"file":"query-client.d.mts","names":[],"sources":["../src/query-client.ts"],"mappings":";;;;UA6BiB,wBAAA;EACf,KAAA,EAAO,WAAA;EACP,MAAA,EAAQ,MAAM;AAAA;AAAA,cAGH,kBAAA;EAAA,QACH,MAAA;EAAA,QACA,KAAA;cAEI,MAAA,EAAQ,wBAAA;EASd,QAAA,CAAS,EAAA,UAAY,OAAA,GAAU,eAAA,GAAe,OAAA;;;;iDAOQ,gBAAA;IAAA;MAAA;;;;wIAepB,gBAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,+JAIN,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,4LAAA,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,yNArC1B,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,sPAEY,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,mRASd,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,gTAA+B,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,6UAAe,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA;;;;;;;;;;;;EAO9C,UAAA,CAAW,IAAA,UAAc,OAAA;IAAY,MAAA;EAAA,IAAiB,OAAA;;;;;;;;2GAAtD,gBAAA,0BAAA,0BAAA;IAAA;MAAA,kIAAqC,gBAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,+JAAiB,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA;;;;;;;;;;;;;;;;;;;;;EAetD,QAAA,CAAS,OAAA,GAAU,eAAA,GAAe,OAAA;;;gZAAzB;MAAA,2CAAyB,gBAAA;IAAA;MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAIlC,KAAA,CAAM,OAAA,GAAU,YAAA,GAAY,OAAA;AAAA;;;;;iBASpB,wBAAA,CAAyB,MAAA,EAAQ,MAAA,GAAS,kBAAkB"}
1
+ {"version":3,"file":"query-client.d.mts","names":[],"sources":["../src/query-client.ts"],"mappings":";;;UA6BiB,wBAAA;EACf,KAAA,EAAO,WAAA;EACP,MAAA,EAAQ,MAAM;AAAA;AAAA,cAGH,kBAAA;EAAA,QACH,MAAA;EAAA,QACA,KAAA;cAEI,MAAA,EAAQ,wBAAA;EASd,QAAA,CAAS,EAAA,UAAY,OAAA,GAAU,eAAA,GAAe,OAAA;;;;iDAOQ,gBAAA;IAAA;MAAA;;;;wIAepB,gBAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,+JAIN,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,4LAAA,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,yNArC1B,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,sPAEY,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,mRASd,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,gTAA+B,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,6UAAe,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA;;;;;;;;;;;;EAO9C,UAAA,CAAW,IAAA,UAAc,OAAA;IAAY,MAAA;EAAA,IAAiB,OAAA;;;;;;;;2GAAtD,gBAAA,0BAAA,0BAAA;IAAA;MAAA,kIAAqC,gBAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA,+JAAiB,gBAAA,0BAAA,0BAAA,0BAAA,0BAAA;IAAA;MAAA;;;;;;;;;;;;;;;;;;;;;EAetD,QAAA,CAAS,OAAA,GAAU,eAAA,GAAe,OAAA;;;gZAAzB;MAAA,2CAAyB,gBAAA;IAAA;MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAIlC,KAAA,CAAM,OAAA,GAAU,YAAA,GAAY,OAAA;AAAA;;;;;iBASpB,wBAAA,CAAyB,MAAA,EAAQ,MAAA,GAAS,kBAAkB"}
@@ -1,2 +1,2 @@
1
- import{t as e}from"./locale-utils-w0WO6J_P.mjs";import"server-only";import{createQueryClient as t}from"@murumets-ee/core/clients";import{schemaRegistry as n}from"@murumets-ee/db";import{eq as r}from"drizzle-orm";var i=class{entity;query;constructor(e){this.entity=e.entity,this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findBySlug(t,i){i?.locale!==void 0&&e(i.locale);let a=n.get(this.entity.name);return!a||!a.slug?null:(await this.query.findMany({where:r(a.slug,t),limit:1,locale:i?.locale}))[0]??null}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};function a(e){return new i({query:t(e),entity:e})}export{i as ContentQueryClient,a as createContentQueryClient};
1
+ import{t as e}from"./locale-utils-CuIBKeaz.mjs";import"server-only";import{createQueryClient as t}from"@murumets-ee/core/clients";import{schemaRegistry as n}from"@murumets-ee/db";import{eq as r}from"drizzle-orm";var i=class{entity;query;constructor(e){this.entity=e.entity,this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findBySlug(t,i){i?.locale!==void 0&&e(i.locale);let a=n.get(this.entity.name);return!a||!a.slug?null:(await this.query.findMany({where:r(a.slug,t),limit:1,locale:i?.locale}))[0]??null}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};function a(e){return new i({query:t(e),entity:e})}export{i as ContentQueryClient,a as createContentQueryClient};
2
2
  //# sourceMappingURL=query-client.mjs.map
@@ -0,0 +1,2 @@
1
+ import{behavior as e,defineEntity as t,field as n}from"@murumets-ee/entity";import{defineSettings as r,setting as i}from"@murumets-ee/settings/define";var a=class extends Error{entityName;capability;constructor(e,t,n){super(n??`Entity "${e}" does not support ${t}. Add the corresponding behavior to defineEntity({ behaviors: [...] }).`),this.name=`EntityCapabilityError`,this.entityName=e,this.capability=t}},o=class extends Error{entityId;locale;constructor(e,t){super(t===`_`?`No pending draft to publish — this document is already up to date.`:`No pending draft to publish for locale "${t}" — it is already up to date.`),this.name=`DraftNotFoundError`,this.entityId=e,this.locale=t}},s=class extends Error{locale;allowed;constructor(e,t){super(`Invalid locale "${e}". Configured locales: [${t.join(`, `)}]. (The "_" sentinel is also accepted for default-locale storage.)`),this.name=`InvalidLocaleError`,this.locale=e,this.allowed=t}};const c=[`prefix-all`,`no-prefix-default`,`cookie`],l=r({namespace:`i18n`,scope:`global`,label:`Languages`,iconName:`languages`,hideFromMenu:!0,schema:{defaultLocale:i.text({default:``}),routingStrategy:i.select({options:c,default:`no-prefix-default`})}}),u=t({name:`locale`,class:`config`,scope:`global`,access:{view:`locale:view`,create:`locale:create`,update:`locale:update`,delete:`locale:delete`},fields:{code:n.text({required:!0,unique:!0,maxLength:10}),label:n.text({required:!0,maxLength:64}),enabled:n.boolean({default:!0}),direction:n.select({options:[`ltr`,`rtl`],default:`ltr`}),sortOrder:n.number({default:0})},behaviors:[e.auditable()],admin:{hidden:!0,label:`Languages`,labelSingular:`Language`}}),d=Symbol.for(`@murumets-ee/content:content-config`);function f(e){globalThis[d]=e}function p(){let e=globalThis[d];if(!e)throw Error(`@murumets-ee/content plugin not initialized. Add content() to your plugins array.`);return e}function m(e){switch(e){case`prefix-all`:return`always`;case`no-prefix-default`:return`as-needed`;case`cookie`:return`never`}}function h(e){return e===`prefix-all`}export{u as a,o as c,f as i,a as l,h as n,c as o,p as r,l as s,m as t,s as u};
2
+ //# sourceMappingURL=routing-strategy-DGx6Rq6F.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing-strategy-DGx6Rq6F.mjs","names":[],"sources":["../src/errors.ts","../src/i18n-settings.ts","../src/locale-entity.ts","../src/content-config-store.ts","../src/routing-strategy.ts"],"sourcesContent":["/**\n * Domain errors for ContentClient + LockService.\n */\n\n/**\n * Thrown when a caller invokes an entity-capability-specific method on an\n * entity that doesn't have the corresponding behavior — e.g. `saveDraft`\n * on a non-publishable entity, `setLocaleStatus` on an entity without\n * publishable+translatable, `restoreVersion` on a non-versionable entity.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 400 Bad Request (caller's fault) rather than 500 (server bug).\n */\nexport class EntityCapabilityError extends Error {\n readonly entityName: string\n readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n constructor(\n entityName: string,\n capability: EntityCapabilityError['capability'],\n message?: string,\n ) {\n super(\n message ??\n `Entity \"${entityName}\" does not support ${capability}. ` +\n `Add the corresponding behavior to defineEntity({ behaviors: [...] }).`,\n )\n this.name = 'EntityCapabilityError'\n this.entityName = entityName\n this.capability = capability\n }\n}\n\n/**\n * Thrown by `publishDraft` when there is no pending draft for the requested\n * entity+locale — publishing is a promote-the-draft operation, so \"nothing\n * to promote\" is a caller-state condition, not a server fault.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 409 Conflict with a human-readable message instead of a generic 500\n * (which is what users saw pre-PR-23 — F031 #1).\n */\nexport class DraftNotFoundError extends Error {\n readonly entityId: string\n readonly locale: string\n\n constructor(entityId: string, locale: string) {\n // NB: the `_` default-locale sentinel is an internal storage key — never\n // show it to users (PR 23 user-smoke find).\n super(\n locale === '_'\n ? 'No pending draft to publish — this document is already up to date.'\n : `No pending draft to publish for locale \"${locale}\" — it is already up to date.`,\n )\n this.name = 'DraftNotFoundError'\n this.entityId = entityId\n this.locale = locale\n }\n}\n\n/**\n * Thrown when a locale string passed at runtime is not configured in the\n * content plugin. The plugin validates `defaultLocale` at init time, but\n * the per-call `locale` parameter on every read/write is not naturally\n * checked — `validateLocale()` is the boundary check.\n *\n * Allows the `'_'` sentinel (used internally for default-locale rows in\n * versions/drafts/locale_status tables) so callers can pass it through.\n */\nexport class InvalidLocaleError extends Error {\n readonly locale: string\n readonly allowed: readonly string[]\n\n constructor(locale: string, allowed: readonly string[]) {\n super(\n `Invalid locale \"${locale}\". Configured locales: [${allowed.join(', ')}]. ` +\n `(The \"_\" sentinel is also accepted for default-locale storage.)`,\n )\n this.name = 'InvalidLocaleError'\n this.locale = locale\n this.allowed = allowed\n }\n}\n","/**\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 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","/**\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","/**\n * Routing-strategy → framework-config mappings (plan/config phase 05 §7, D009/SD006).\n *\n * The `i18n` config's `routingStrategy` is the tenant-editable, framework-neutral\n * choice; these pure helpers are the **\"consumers read it\"** seam that maps it to\n * each frontend's native knob:\n *\n * - **next-intl** — `localePrefix` (`'always'` / `'as-needed'` / `'never'`).\n * - **Astro** — `i18n.routing.prefixDefaultLocale` (boolean).\n *\n * Kept dependency-free (no entity/settings/db import) so the public Astro build\n * (`astro.config`) and next-intl `routing.ts` can import the mapping without\n * dragging a `server-only` chain.\n */\n\nimport type { RoutingStrategy } from './types.js'\n\n/**\n * Map a {@link RoutingStrategy} to next-intl's `localePrefix`:\n *\n * | strategy | localePrefix | URL shape |\n * |---------------------|--------------|-----------------------------------|\n * | `prefix-all` | `always` | `/en/about`, `/et/about` |\n * | `no-prefix-default` | `as-needed` | `/about` (default), `/et/about` |\n * | `cookie` | `never` | `/about` (locale via cookie/edge) |\n */\nexport function localePrefixForStrategy(\n strategy: RoutingStrategy,\n): 'always' | 'as-needed' | 'never' {\n switch (strategy) {\n case 'prefix-all':\n return 'always'\n case 'no-prefix-default':\n return 'as-needed'\n case 'cookie':\n return 'never'\n }\n}\n\n/**\n * Map a {@link RoutingStrategy} to Astro's `i18n.routing.prefixDefaultLocale`.\n * Only `prefix-all` prefixes the default locale; the other two leave the default\n * unprefixed (`cookie` resolves the locale at the edge worker, not in the path).\n */\nexport function prefixDefaultLocaleForStrategy(strategy: RoutingStrategy): boolean {\n return strategy === 'prefix-all'\n}\n"],"mappings":"uJAaA,IAAa,EAAb,cAA2C,KAAM,CAC/C,WACA,WAEA,YACE,EACA,EACA,EACA,CACA,MACE,GACE,WAAW,EAAW,qBAAqB,EAAW,wEAE1D,EACA,KAAK,KAAO,wBACZ,KAAK,WAAa,EAClB,KAAK,WAAa,CACpB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,SACA,OAEA,YAAY,EAAkB,EAAgB,CAG5C,MACE,IAAW,IACP,qEACA,2CAA2C,EAAO,8BACxD,EACA,KAAK,KAAO,qBACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,OACA,QAEA,YAAY,EAAgB,EAA4B,CACtD,MACE,mBAAmB,EAAO,0BAA0B,EAAQ,KAAK,IAAI,EAAE,mEAEzE,EACA,KAAK,KAAO,qBACZ,KAAK,OAAS,EACd,KAAK,QAAU,CACjB,CACF,ECjEA,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,SACP,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,ECvCK,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,CCxBA,SAAgB,EACd,EACkC,CAClC,OAAQ,EAAR,CACE,IAAK,aACH,MAAO,SACT,IAAK,oBACH,MAAO,YACT,IAAK,SACH,MAAO,OACX,CACF,CAOA,SAAgB,EAA+B,EAAoC,CACjF,OAAO,IAAa,YACtB"}
@@ -0,0 +1,121 @@
1
+ import { l as RoutingStrategy } from "./types-CUw2Pf0Y.mjs";
2
+
3
+ //#region src/i18n-settings.d.ts
4
+ /**
5
+ * `i18n` — the global i18n singletons **simple-config** namespace (plan/config
6
+ * phase 05 §3, D002/D009/SD006): `defaultLocale` + `routingStrategy`.
7
+ *
8
+ * Simple config (D002) rather than a config entity because these are singletons,
9
+ * not a list. Contributed via the content plugin's `shared.settings` slot, so it
10
+ * auto-registers as a config type (D006) and rides export/import (phase 09) for
11
+ * free. Edited through the bespoke **Languages** editor (`hideFromMenu`), which
12
+ * also enforces `defaultLocale ∈ enabled locales`.
13
+ *
14
+ * Imports from `@murumets-ee/settings/define` (the pure builder subpath, no
15
+ * `server-only`) so the content plugin stays jiti-safe at config-load time.
16
+ */
17
+ /** The three URL locale-routing strategies (D009). */
18
+ declare const ROUTING_STRATEGIES: readonly ["prefix-all", "no-prefix-default", "cookie"];
19
+ declare const i18nSettings: import("@murumets-ee/settings/define").SettingsDefinition<{
20
+ /**
21
+ * The site-wide default content locale. Empty ⇒ inherit the `content()`
22
+ * shipped `defaultLocale`. The resolver ({@link resolveI18nConfig}) validates
23
+ * it is an **enabled** locale and falls back to the `content()` default if not.
24
+ */
25
+ readonly defaultLocale: import("@murumets-ee/settings/define").TextSettingConfig & {
26
+ readonly default: "";
27
+ };
28
+ /**
29
+ * How the public URL encodes locale. Maps to next-intl `localePrefix` /
30
+ * Astro `prefixDefaultLocale` via `./routing-strategy.js`.
31
+ */
32
+ readonly routingStrategy: import("@murumets-ee/settings/define").SelectSettingConfig<readonly ["prefix-all", "no-prefix-default", "cookie"]> & {
33
+ readonly options: readonly ["prefix-all", "no-prefix-default", "cookie"];
34
+ readonly default: "no-prefix-default";
35
+ };
36
+ }, "i18n">;
37
+ //#endregion
38
+ //#region src/locale-entity.d.ts
39
+ /**
40
+ * `locale` — the content-locale registry **config entity** (plan/config phase 05
41
+ * §3, D006/D009/SD006).
42
+ *
43
+ * One row per content locale the operator adds or customizes. The row is a
44
+ * **read-time-merge override** (D004): a row whose `code` matches a `content()`
45
+ * shipped default replaces that default (relabel / set direction / reorder /
46
+ * `enabled: false` to hide it); a row with a new `code` adds a locale. Locales
47
+ * with no row are the shipped defaults. See {@link resolveEffectiveLocales}.
48
+ *
49
+ * `class: 'config'` (SD001) marks it a configuration entity: it auto-registers
50
+ * into the `ConfigTypeRegistry` (phase-09 export/import picks it up for free),
51
+ * defaults to audit-only (no publish / no trash — trash auto-enable skips config
52
+ * entities), and is edited through the bespoke **Languages** editor, not the
53
+ * generic auto-CRUD (`admin.hidden`).
54
+ *
55
+ * `access` is admin-only: managing the language set is an admin capability. The
56
+ * app-wide *effective-locale read* ({@link resolveEffectiveLocales}) uses a
57
+ * system-context query so every role still resolves valid locales for editing
58
+ * content, without being able to list/edit the registry itself.
59
+ *
60
+ * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin can pull
61
+ * it in without a `server-only` chain through the jiti config load.
62
+ */
63
+ declare const Locale: import("@murumets-ee/entity").Entity<{
64
+ id: import("@murumets-ee/entity").IdField;
65
+ } & import("@murumets-ee/entity").AuditableFields & {
66
+ /**
67
+ * BCP47 locale code (e.g. `en`, `et`, `pt-BR`). Unique — one row per locale.
68
+ * Capped at 10 to match the `varchar(10)` locale columns in the
69
+ * versions / drafts / locale_status tables (truncation past 10 would break
70
+ * later equality — see `locale-utils.ts`).
71
+ */
72
+ code: import("@murumets-ee/entity").TextField & {
73
+ readonly required: true;
74
+ readonly unique: true;
75
+ readonly maxLength: 10;
76
+ }; /** Human label shown in the switcher + editor (e.g. `English`). */
77
+ label: import("@murumets-ee/entity").TextField & {
78
+ readonly required: true;
79
+ readonly maxLength: 64;
80
+ };
81
+ /**
82
+ * When `false` the locale is hidden from the content switcher + rejected by
83
+ * `validateLocale` (the D004 disable-not-delete edge), but stays listable in
84
+ * the Languages editor so it can be re-enabled.
85
+ */
86
+ enabled: import("@murumets-ee/entity").BooleanField & {
87
+ readonly default: true;
88
+ };
89
+ /** Text direction (`ltr`/`rtl`) — persisted per locale for RTL-aware surfaces to read
90
+ * when setting `dir`; the surface-level `dir` wiring lands with RTL support. */
91
+ direction: import("@murumets-ee/entity").SelectField & {
92
+ options: readonly ["ltr", "rtl"];
93
+ } & {
94
+ readonly options: readonly ["ltr", "rtl"];
95
+ readonly default: "ltr";
96
+ }; /** Display order in the switcher + editor (ascending). */
97
+ sortOrder: import("@murumets-ee/entity").NumberField & {
98
+ readonly default: 0;
99
+ };
100
+ }, "locale", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>]>;
101
+ //#endregion
102
+ //#region src/routing-strategy.d.ts
103
+ /**
104
+ * Map a {@link RoutingStrategy} to next-intl's `localePrefix`:
105
+ *
106
+ * | strategy | localePrefix | URL shape |
107
+ * |---------------------|--------------|-----------------------------------|
108
+ * | `prefix-all` | `always` | `/en/about`, `/et/about` |
109
+ * | `no-prefix-default` | `as-needed` | `/about` (default), `/et/about` |
110
+ * | `cookie` | `never` | `/about` (locale via cookie/edge) |
111
+ */
112
+ declare function localePrefixForStrategy(strategy: RoutingStrategy): 'always' | 'as-needed' | 'never';
113
+ /**
114
+ * Map a {@link RoutingStrategy} to Astro's `i18n.routing.prefixDefaultLocale`.
115
+ * Only `prefix-all` prefixes the default locale; the other two leave the default
116
+ * unprefixed (`cookie` resolves the locale at the edge worker, not in the path).
117
+ */
118
+ declare function prefixDefaultLocaleForStrategy(strategy: RoutingStrategy): boolean;
119
+ //#endregion
120
+ export { i18nSettings as a, ROUTING_STRATEGIES as i, prefixDefaultLocaleForStrategy as n, Locale as r, localePrefixForStrategy as t };
121
+ //# sourceMappingURL=routing-strategy-TPSzrapD.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing-strategy-TPSzrapD.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;AAiBA;;;;AAAwF;AAExF;;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,yCAAY,kBAAA;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBCDH,uBAAA,CACd,QAAyB,EAAf,eAAe;ADA3B;;;;;AAAA,iBCiBgB,8BAAA,CAA+B,QAAyB,EAAf,eAAe"}
@@ -6,6 +6,40 @@ interface LocaleConfig {
6
6
  code: string;
7
7
  label: string;
8
8
  }
9
+ /** Text direction of a content locale. */
10
+ type LocaleDirection = 'ltr' | 'rtl';
11
+ /**
12
+ * URL locale-routing strategy (plan/config D009). Maps to next-intl `localePrefix`
13
+ * (`always` / `as-needed` / `never`) and Astro `prefixDefaultLocale` — see
14
+ * {@link localePrefixForStrategy} / {@link prefixDefaultLocaleForStrategy} in
15
+ * `./routing-strategy.js`.
16
+ */
17
+ type RoutingStrategy = 'prefix-all' | 'no-prefix-default' | 'cookie';
18
+ /**
19
+ * A fully-resolved content locale — the read-time-merge (D004/SD006) of a
20
+ * code-shipped `content()` default ({@link LocaleConfig}) with its DB `locale`
21
+ * config-entity override row. This is the effective shape every runtime consumer
22
+ * (admin content-locale switcher, entity-editor locale list, the Languages editor)
23
+ * reads. `enabled: false` locales stay listable in the editor but are excluded
24
+ * from the switcher + `validateLocale` whitelist.
25
+ */
26
+ interface LocaleEntry {
27
+ code: string;
28
+ label: string;
29
+ enabled: boolean;
30
+ direction: LocaleDirection;
31
+ sortOrder: number;
32
+ }
33
+ /**
34
+ * The resolved global i18n singletons — the `i18n` simple-config namespace
35
+ * (`defaultLocale`, `routingStrategy`), read-time-merged over the `content()`
36
+ * defaults. `defaultLocale` is always an **enabled** locale code (the resolver
37
+ * falls back to the `content()` default if the configured one isn't enabled).
38
+ */
39
+ interface I18nConfig {
40
+ defaultLocale: string;
41
+ routingStrategy: RoutingStrategy;
42
+ }
9
43
  interface ContentConfig {
10
44
  locales: LocaleConfig[];
11
45
  defaultLocale: string;
@@ -82,5 +116,5 @@ interface LockInfo {
82
116
  expiresAt: Date | string;
83
117
  }
84
118
  //#endregion
85
- export { LockInfo as a, TranslationStatus as c, LocalePublishStatus as i, VersionEntry as l, DraftEntry as n, TranslationFieldStatus as o, LocaleConfig as r, TranslationReport as s, ContentConfig as t, VersionableOptions as u };
86
- //# sourceMappingURL=types-yRLOOCOn.d.mts.map
119
+ export { LocaleDirection as a, LockInfo as c, TranslationReport as d, TranslationStatus as f, LocaleConfig as i, RoutingStrategy as l, VersionableOptions as m, DraftEntry as n, LocaleEntry as o, VersionEntry as p, I18nConfig as r, LocalePublishStatus as s, ContentConfig as t, TranslationFieldStatus as u };
120
+ //# sourceMappingURL=types-CUw2Pf0Y.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-CUw2Pf0Y.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;AAQA;;UAAiB,YAAA;EACf,IAAA;EACA,KAAK;AAAA;;KAIK,eAAA;;AAAe;AAQ3B;;;;KAAY,eAAA;AAUZ;;;;;;;;AAAA,UAAiB,WAAA;EACf,IAAA;EACA,KAAA;EACA,OAAA;EACA,SAAA,EAAW,eAAe;EAC1B,SAAA;AAAA;;;;;;AAWgC;UAFjB,UAAA;EACf,aAAA;EACA,eAAA,EAAiB,eAAe;AAAA;AAAA,UAGjB,aAAA;EACf,OAAA,EAAS,YAAY;EACrB,aAAA;EAcA;;;AAA+B;AAOjC;;;;AAIU;AAOV;;;;EAlBE,SAAA;IAAwB,OAAA;EAAA;AAAA;AAAA,UAOT,kBAAA;EAqBA;EAnBf,SAAA;EAS4B;EAP5B,QAAQ;AAAA;AAAA,UAOO,YAAA,eAA2B,MAAA,oBAA0B,MAAA;EACpE,EAAA;EACA,QAAA;EACA,OAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,KAAA,EAAO,OAAA,CAAQ,KAAA;EACf,MAAA;EACA,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,UAAA;AAAA;AAAA,UAae,sBAAA;EACf,QAAA;EACA,KAAA;EACA,UAAA;EACA,OAAA;AAAA;AAAA,UAGe,iBAAA;EAAA,CACd,MAAA,WAAiB,sBAAsB;AAAA;AAAA,UAGzB,iBAAA;EACf,KAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAOe,mBAAA;EACf,MAAA;EACA,MAAA;EACA,WAAA,EAAa,IAAI;AAAA;AAAA,UAOF,UAAA,eAAyB,MAAA,oBAA0B,MAAA;EAClE,EAAA;EACA,QAAA;EACA,MAAA;EACA,IAAA,EAAM,KAAA;EACN,SAAA;EACA,aAAA;EACA,SAAA,EAAW,IAAA;EACX,SAAA,EAAW,IAAA;AAAA;AAAA,UAOI,QAAA;EACf,QAAA;EACA,YAAA;EACA,QAAA,EAAU,IAAA;EACV,SAAA,EAAW,IAAI;AAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/content",
3
- "version": "0.34.1",
3
+ "version": "0.35.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,11 +36,12 @@
36
36
  "drizzle-orm": "^0.45.2",
37
37
  "server-only": "^0.0.1",
38
38
  "zod": "^3.24.1",
39
- "@murumets-ee/blocks": "0.34.1",
40
- "@murumets-ee/core": "0.34.1",
41
- "@murumets-ee/db": "0.34.1",
42
- "@murumets-ee/logging": "0.34.1",
43
- "@murumets-ee/entity": "0.34.1"
39
+ "@murumets-ee/blocks": "0.35.1",
40
+ "@murumets-ee/core": "0.35.1",
41
+ "@murumets-ee/db": "0.35.1",
42
+ "@murumets-ee/entity": "0.35.1",
43
+ "@murumets-ee/logging": "0.35.1",
44
+ "@murumets-ee/settings": "0.35.1"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/node": "^20.19.42",
@@ -1,2 +0,0 @@
1
- var e=class extends Error{entityName;capability;constructor(e,t,n){super(n??`Entity "${e}" does not support ${t}. Add the corresponding behavior to defineEntity({ behaviors: [...] }).`),this.name=`EntityCapabilityError`,this.entityName=e,this.capability=t}},t=class extends Error{entityId;locale;constructor(e,t){super(t===`_`?`No pending draft to publish — this document is already up to date.`:`No pending draft to publish for locale "${t}" — it is already up to date.`),this.name=`DraftNotFoundError`,this.entityId=e,this.locale=t}},n=class extends Error{locale;allowed;constructor(e,t){super(`Invalid locale "${e}". Configured locales: [${t.join(`, `)}]. (The "_" sentinel is also accepted for default-locale storage.)`),this.name=`InvalidLocaleError`,this.locale=e,this.allowed=t}};export{e as n,n as r,t};
2
- //# sourceMappingURL=errors-bZAGPYhp.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors-bZAGPYhp.mjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Domain errors for ContentClient + LockService.\n */\n\n/**\n * Thrown when a caller invokes an entity-capability-specific method on an\n * entity that doesn't have the corresponding behavior — e.g. `saveDraft`\n * on a non-publishable entity, `setLocaleStatus` on an entity without\n * publishable+translatable, `restoreVersion` on a non-versionable entity.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 400 Bad Request (caller's fault) rather than 500 (server bug).\n */\nexport class EntityCapabilityError extends Error {\n readonly entityName: string\n readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n constructor(\n entityName: string,\n capability: EntityCapabilityError['capability'],\n message?: string,\n ) {\n super(\n message ??\n `Entity \"${entityName}\" does not support ${capability}. ` +\n `Add the corresponding behavior to defineEntity({ behaviors: [...] }).`,\n )\n this.name = 'EntityCapabilityError'\n this.entityName = entityName\n this.capability = capability\n }\n}\n\n/**\n * Thrown by `publishDraft` when there is no pending draft for the requested\n * entity+locale — publishing is a promote-the-draft operation, so \"nothing\n * to promote\" is a caller-state condition, not a server fault.\n *\n * Distinct from generic `Error` so admin-ui's API handler can map it to\n * 409 Conflict with a human-readable message instead of a generic 500\n * (which is what users saw pre-PR-23 — F031 #1).\n */\nexport class DraftNotFoundError extends Error {\n readonly entityId: string\n readonly locale: string\n\n constructor(entityId: string, locale: string) {\n // NB: the `_` default-locale sentinel is an internal storage key — never\n // show it to users (PR 23 user-smoke find).\n super(\n locale === '_'\n ? 'No pending draft to publish — this document is already up to date.'\n : `No pending draft to publish for locale \"${locale}\" — it is already up to date.`,\n )\n this.name = 'DraftNotFoundError'\n this.entityId = entityId\n this.locale = locale\n }\n}\n\n/**\n * Thrown when a locale string passed at runtime is not configured in the\n * content plugin. The plugin validates `defaultLocale` at init time, but\n * the per-call `locale` parameter on every read/write is not naturally\n * checked — `validateLocale()` is the boundary check.\n *\n * Allows the `'_'` sentinel (used internally for default-locale rows in\n * versions/drafts/locale_status tables) so callers can pass it through.\n */\nexport class InvalidLocaleError extends Error {\n readonly locale: string\n readonly allowed: readonly string[]\n\n constructor(locale: string, allowed: readonly string[]) {\n super(\n `Invalid locale \"${locale}\". Configured locales: [${allowed.join(', ')}]. ` +\n `(The \"_\" sentinel is also accepted for default-locale storage.)`,\n )\n this.name = 'InvalidLocaleError'\n this.locale = locale\n this.allowed = allowed\n }\n}\n"],"mappings":"AAaA,IAAa,EAAb,cAA2C,KAAM,CAC/C,WACA,WAEA,YACE,EACA,EACA,EACA,CACA,MACE,GACE,WAAW,EAAW,qBAAqB,EAAW,wEAE1D,EACA,KAAK,KAAO,wBACZ,KAAK,WAAa,EAClB,KAAK,WAAa,CACpB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,SACA,OAEA,YAAY,EAAkB,EAAgB,CAG5C,MACE,IAAW,IACP,qEACA,2CAA2C,EAAO,8BACxD,EACA,KAAK,KAAO,qBACZ,KAAK,SAAW,EAChB,KAAK,OAAS,CAChB,CACF,EAWa,EAAb,cAAwC,KAAM,CAC5C,OACA,QAEA,YAAY,EAAgB,EAA4B,CACtD,MACE,mBAAmB,EAAO,0BAA0B,EAAQ,KAAK,IAAI,EAAE,mEAEzE,EACA,KAAK,KAAO,qBACZ,KAAK,OAAS,EACd,KAAK,QAAU,CACjB,CACF"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index-kQaEm4Ft.d.mts","names":[],"sources":["../src/behaviors/versionable.ts","../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/diff/types.ts","../src/errors.ts"],"mappings":";;;;KAmBY,iBAAA;EACV,QAAA,EAAU,WAAA;IAAgB,QAAA;EAAA;EAC1B,YAAA,EAAc,SAAS;AAAA;AAAA,iBAQT,WAAA,CAAY,OAAA,GAAU,kBAAA,GAAqB,QAAA,CAAS,iBAAA;;;;iBAqDpD,qBAAA,CAAsB,SAAA,GAAY,QAAA,KAAa,QAAA,CAAS,kBAAA;;;;ACrCV;;;;KAQzD,iBAAA;EACC,IAAA;AAAA;EAEA,IAAA;EACA,KAAA,GAAQ,MAAA;EACR,MAAA,GAAS,MAAA,SAAe,MAAA;AAAA;EAExB,IAAA;EAAiB,KAAA,EAAO,MAAA;AAAA;AAAA,KAEzB,YAAA,GAAe,MAAM,SAAS,iBAAA;AAAA,KAEvB,mBAAA;EAcV,UAAA,EAAY,SAAS;AAAA;;;AAlBa;AAAA;iBAsLpB,aAAA,CAAA,GAAiB,QAAQ,CAAC,mBAAA;;;AApLU;AAEpD;;iBA8NgB,gBAAA,CACd,SAAA,EAAW,aAAa;EAAG,IAAA;AAAA,IAAiB,MAAA;;;cCjPjC,eAAA;AAAA,KACD,aAAA,WAAwB,eAAe;;;;AFmCuC;;;;cE1B7E,oBAAA;EAAA;;;;;;;;;iBAWG,mBAAA,CAAoB,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAa;;;;;cAU7D,uBAAA,SAAgC,KAAA;EAAA,SAClC,IAAA,EAAM,aAAA;EAAA,SACN,EAAA,EAAI,aAAA;cAED,IAAA,EAAM,aAAA,EAAe,EAAA,EAAI,aAAA;AAAA;AAAA,KAW3B,kBAAA;EACV,eAAA,EAAiB,WAAA;IAAgB,QAAA;EAAA;EACjC,iBAAA,EAAmB,SAAA;EACnB,aAAA,EAAe,SAAA;EACf,kBAAA,EAAoB,SAAA;AAAA;AAAA,iBAGN,YAAA,CAAA,GAAgB,QAAQ,CAAC,kBAAA;;;KC7E7B,gBAAA;;;;;;iBAOI,cAAA,CAAe,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,gBAAgB;;;;;;;UCxB1D,WAAA;EACf,IAAA;EACA,KAAK;AAAA;;UAIU,SAAA;EACf,KAAA;EACA,IAAA;EAWA,IAAA;EACA,EAAA;EJJuB;EIMvB,QAAA,GAAW,WAAA;EJEc;EIAzB,UAAA,GAAa,cAAc;AAAA;;KAIjB,eAAA;;UAGK,cAAA;EACf,OAAA;EACA,SAAA;EACA,MAAA,EAAQ,eAAA;EACR,SAAA;EACA,OAAA;EJZmF;EIcnF,SAAA,GAAY,QAAQ;AAAA;;UAIL,QAAA;EACf,KAAA;EACA,IAAA;EACA,EAAA;EJgCqE;EI9BrE,QAAA,GAAW,WAAW;AAAA;;;;;;;AJjCxB;;;;;;;;cKNa,qBAAA,SAA8B,KAAK;EAAA,SACrC,UAAA;EAAA,SACA,UAAA;cAGP,UAAA,UACA,UAAA,EAAY,qBAAA,gBACZ,OAAA;AAAA;;;;;;;;;;cAsBS,kBAAA,SAA2B,KAAK;EAAA,SAClC,QAAA;EAAA,SACA,MAAA;cAEG,QAAA,UAAkB,MAAA;AAAA;;;;;;;;;;cAuBnB,kBAAA,SAA2B,KAAK;EAAA,SAClC,MAAA;EAAA,SACA,OAAA;cAEG,MAAA,UAAgB,OAAA;AAAA"}
@@ -1,2 +0,0 @@
1
- import{r as e}from"./errors-bZAGPYhp.mjs";import{n as t}from"./plugin-CG2tNeoG.mjs";function n(n){if(n===`_`)return;let r=t();for(let e of r.locales)if(e.code===n)return;throw new e(n,r.locales.map(e=>e.code))}export{n as t};
2
- //# sourceMappingURL=locale-utils-w0WO6J_P.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"locale-utils-w0WO6J_P.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 { InvalidLocaleError } from './errors.js'\nimport { getContentConfig } from './plugin.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\n * one of the configured plugin locale codes.\n */\nexport function validateLocale(locale: string): void {\n if (locale === DEFAULT_LOCALE_SENTINEL) return\n const config = getContentConfig()\n for (const l of config.locales) {\n if (l.code === locale) return\n }\n throw new InvalidLocaleError(\n locale,\n config.locales.map((l) => l.code),\n )\n}\n"],"mappings":"oFAsCA,SAAgB,EAAe,EAAsB,CACnD,GAAI,IAAA,IAAoC,OACxC,IAAM,EAAS,EAAiB,EAChC,IAAK,IAAM,KAAK,EAAO,QACrB,GAAI,EAAE,OAAS,EAAQ,OAEzB,MAAM,IAAI,EACR,EACA,EAAO,QAAQ,IAAK,GAAM,EAAE,IAAI,CAClC,CACF"}