@murumets-ee/content 0.0.0-preview-20261014110621

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 (111) hide show
  1. package/LICENSE +94 -0
  2. package/README.md +15 -0
  3. package/dist/admin.d.mts +56 -0
  4. package/dist/admin.d.mts.map +1 -0
  5. package/dist/admin.mjs +1 -0
  6. package/dist/block-resolver.d.mts +320 -0
  7. package/dist/block-resolver.d.mts.map +1 -0
  8. package/dist/block-resolver.mjs +2 -0
  9. package/dist/block-resolver.mjs.map +1 -0
  10. package/dist/breadcrumbs-BqSuMNac.mjs +2 -0
  11. package/dist/breadcrumbs-BqSuMNac.mjs.map +1 -0
  12. package/dist/chrome-entity-names-zYJzP-_U.mjs +2 -0
  13. package/dist/chrome-entity-names-zYJzP-_U.mjs.map +1 -0
  14. package/dist/chrome-provider.d.mts +143 -0
  15. package/dist/chrome-provider.d.mts.map +1 -0
  16. package/dist/chrome-provider.mjs +2 -0
  17. package/dist/chrome-provider.mjs.map +1 -0
  18. package/dist/client.d.mts +89 -0
  19. package/dist/client.d.mts.map +1 -0
  20. package/dist/client.mjs +2 -0
  21. package/dist/client.mjs.map +1 -0
  22. package/dist/content-config-store-8omQF5p5.mjs +2 -0
  23. package/dist/content-config-store-8omQF5p5.mjs.map +1 -0
  24. package/dist/context-D7eEItCY.mjs +2 -0
  25. package/dist/context-D7eEItCY.mjs.map +1 -0
  26. package/dist/detail-template-names-CD1Y9n1U.mjs +2 -0
  27. package/dist/detail-template-names-CD1Y9n1U.mjs.map +1 -0
  28. package/dist/detail-template-provider.d.mts +28 -0
  29. package/dist/detail-template-provider.d.mts.map +1 -0
  30. package/dist/detail-template-provider.mjs +2 -0
  31. package/dist/detail-template-provider.mjs.map +1 -0
  32. package/dist/document-site-B8tmEqLw.mjs +2 -0
  33. package/dist/document-site-B8tmEqLw.mjs.map +1 -0
  34. package/dist/entities/index.d.mts +64 -0
  35. package/dist/entities/index.d.mts.map +1 -0
  36. package/dist/entities/index.mjs +2 -0
  37. package/dist/entities/index.mjs.map +1 -0
  38. package/dist/enumerate-CYHJKQz6.mjs +2 -0
  39. package/dist/enumerate-CYHJKQz6.mjs.map +1 -0
  40. package/dist/errors-NRNILhhm.mjs +2 -0
  41. package/dist/errors-NRNILhhm.mjs.map +1 -0
  42. package/dist/index-cT8Qk4gu.d.mts +275 -0
  43. package/dist/index-cT8Qk4gu.d.mts.map +1 -0
  44. package/dist/index.d.mts +5 -0
  45. package/dist/index.mjs +2 -0
  46. package/dist/index.mjs.map +1 -0
  47. package/dist/locale-entity-CoSq3C77.mjs +2 -0
  48. package/dist/locale-entity-CoSq3C77.mjs.map +1 -0
  49. package/dist/locale-registry-COsJrVZl.mjs +2 -0
  50. package/dist/locale-registry-COsJrVZl.mjs.map +1 -0
  51. package/dist/locale-utils-B0mFoV47.mjs +2 -0
  52. package/dist/locale-utils-B0mFoV47.mjs.map +1 -0
  53. package/dist/lock-service.d.mts +58 -0
  54. package/dist/lock-service.d.mts.map +1 -0
  55. package/dist/lock-service.mjs +2 -0
  56. package/dist/lock-service.mjs.map +1 -0
  57. package/dist/locks-5779ugSW.mjs +2 -0
  58. package/dist/locks-5779ugSW.mjs.map +1 -0
  59. package/dist/plugin-CjgqBSFy.mjs +2 -0
  60. package/dist/plugin-CjgqBSFy.mjs.map +1 -0
  61. package/dist/plugin.d.mts +41 -0
  62. package/dist/plugin.d.mts.map +1 -0
  63. package/dist/plugin.mjs +1 -0
  64. package/dist/query-client.d.mts +131 -0
  65. package/dist/query-client.d.mts.map +1 -0
  66. package/dist/query-client.mjs +2 -0
  67. package/dist/query-client.mjs.map +1 -0
  68. package/dist/registry-CaPbY5lm.mjs +2 -0
  69. package/dist/registry-CaPbY5lm.mjs.map +1 -0
  70. package/dist/resolve-context-DklN53BH.mjs +2 -0
  71. package/dist/resolve-context-DklN53BH.mjs.map +1 -0
  72. package/dist/resolve.d.mts +651 -0
  73. package/dist/resolve.d.mts.map +1 -0
  74. package/dist/resolve.mjs +9 -0
  75. package/dist/resolve.mjs.map +1 -0
  76. package/dist/rolldown-runtime-DK3Fl9T5.mjs +1 -0
  77. package/dist/routes-siXmjKDA.mjs +2 -0
  78. package/dist/routes-siXmjKDA.mjs.map +1 -0
  79. package/dist/routing-strategy-CAG3tZP0.d.mts +155 -0
  80. package/dist/routing-strategy-CAG3tZP0.d.mts.map +1 -0
  81. package/dist/routing-strategy-D6B4ngT9.mjs +2 -0
  82. package/dist/routing-strategy-D6B4ngT9.mjs.map +1 -0
  83. package/dist/schema-BYrYzsmO.mjs +2 -0
  84. package/dist/schema-BYrYzsmO.mjs.map +1 -0
  85. package/dist/schema-LlK6EkOJ.mjs +2 -0
  86. package/dist/schema-LlK6EkOJ.mjs.map +1 -0
  87. package/dist/seed.d.mts +177 -0
  88. package/dist/seed.d.mts.map +1 -0
  89. package/dist/seed.mjs +2 -0
  90. package/dist/seed.mjs.map +1 -0
  91. package/dist/site-chrome-BAtQaAqC.mjs +2 -0
  92. package/dist/site-chrome-BAtQaAqC.mjs.map +1 -0
  93. package/dist/site-chrome-DNCcePSR.d.mts +98 -0
  94. package/dist/site-chrome-DNCcePSR.d.mts.map +1 -0
  95. package/dist/translation-provenance-B41lEDM-.mjs +2 -0
  96. package/dist/translation-provenance-B41lEDM-.mjs.map +1 -0
  97. package/dist/translation-provenance-BZ0MlbFR.d.mts +253 -0
  98. package/dist/translation-provenance-BZ0MlbFR.d.mts.map +1 -0
  99. package/dist/translation.d.mts +144 -0
  100. package/dist/translation.d.mts.map +1 -0
  101. package/dist/translation.mjs +2 -0
  102. package/dist/translation.mjs.map +1 -0
  103. package/dist/types-BOk9tg_9.d.mts +150 -0
  104. package/dist/types-BOk9tg_9.d.mts.map +1 -0
  105. package/dist/versionable-Br_G5VGR.mjs +2 -0
  106. package/dist/versionable-Br_G5VGR.mjs.map +1 -0
  107. package/dist/versionable-CRvV87nZ.d.mts +18 -0
  108. package/dist/versionable-CRvV87nZ.d.mts.map +1 -0
  109. package/dist/versions-w2ui-Vzb.mjs +2 -0
  110. package/dist/versions-w2ui-Vzb.mjs.map +1 -0
  111. package/package.json +93 -0
@@ -0,0 +1,275 @@
1
+ import { o as LocaleEntry, r as I18nConfig } from "./types-BOk9tg_9.mjs";
2
+ import { Behavior, DateField, Entity, JsonField, SelectField, TextField } from "@murumets-ee/entity";
3
+ import { ToolkitApp } from "@murumets-ee/core";
4
+ import { MessageEntry } from "@murumets-ee/i18n";
5
+
6
+ //#region src/behaviors/with-overrides.d.ts
7
+ /**
8
+ * Override-directive shape. Mirrored from `@murumets-ee/blocks` to avoid
9
+ * a circular dep (content → blocks → content); content stores the
10
+ * persisted JSON, blocks owns the resolver. Keep the two shapes in sync.
11
+ */
12
+ type OverrideDirective = {
13
+ kind: 'hide';
14
+ } | {
15
+ kind: 'patch';
16
+ props?: Record<string, unknown>;
17
+ traits?: Record<string, Record<string, unknown>>;
18
+ } | {
19
+ kind: 'replace';
20
+ block: Record<string, unknown>;
21
+ };
22
+ type OverridesMap = Record<string, OverrideDirective>;
23
+ type WithOverridesFields = {
24
+ _overrides: JsonField;
25
+ };
26
+ /**
27
+ * Mark an entity as participating in the page-scoped override resolver.
28
+ * See module-level JSDoc for the full contract.
29
+ */
30
+ declare function withOverrides(): Behavior<WithOverridesFields, 'withOverrides'>;
31
+ /**
32
+ * Check whether an entity declares `withOverrides()`. Returns `true`
33
+ * when the entity's behaviors list contains a behavior tagged with
34
+ * the marker symbol. Used by the boot orphan-sweep + admin UI gating.
35
+ */
36
+ declare function hasWithOverrides(behaviors: ReadonlyArray<{
37
+ name: string;
38
+ } & Record<symbol, unknown>> | undefined): boolean;
39
+ //#endregion
40
+ //#region src/behaviors/workflowable.d.ts
41
+ declare const WORKFLOW_STATES: readonly ["draft", "in_review", "approved", "rejected", "published"];
42
+ type WorkflowState = (typeof WORKFLOW_STATES)[number];
43
+ /**
44
+ * Allowed transitions table. Each key is the *current* workflow status;
45
+ * the value is the set of statuses it can move to.
46
+ *
47
+ * Encoded as a const-asserted record so a typo in the source produces
48
+ * a compile error rather than a silent no-match at runtime.
49
+ */
50
+ declare const WORKFLOW_TRANSITIONS: {
51
+ readonly draft: readonly ["in_review"];
52
+ readonly in_review: readonly ["approved", "rejected"];
53
+ readonly approved: readonly ["published", "in_review"];
54
+ readonly rejected: readonly ["in_review", "draft"];
55
+ readonly published: readonly ["draft"];
56
+ };
57
+ /**
58
+ * Is `to` an allowed transition from `from`? Pure, no I/O.
59
+ */
60
+ declare function isAllowedTransition(from: WorkflowState, to: WorkflowState): boolean;
61
+ /**
62
+ * Thrown when `beforeUpdate` rejects a workflow transition. Distinct
63
+ * subclass so admin-ui's API handler can map it to HTTP 400.
64
+ */
65
+ declare class WorkflowTransitionError extends Error {
66
+ readonly from: WorkflowState;
67
+ readonly to: WorkflowState;
68
+ constructor(from: WorkflowState, to: WorkflowState);
69
+ }
70
+ type WorkflowableFields = {
71
+ _workflowStatus: SelectField & {
72
+ required: true;
73
+ };
74
+ _workflowAssignee: TextField;
75
+ _workflowNote: TextField;
76
+ _workflowUpdatedAt: DateField;
77
+ };
78
+ declare function workflowable(): Behavior<WorkflowableFields>;
79
+ //#endregion
80
+ //#region src/capabilities.d.ts
81
+ type EntityCapability = 'drafts' | 'versions' | 'localeStatus' | 'translations';
82
+ /**
83
+ * Does this entity support the given content capability? Pure check
84
+ * against the entity's declared behaviors and field translatability —
85
+ * no plugin lookup, no DB call.
86
+ */
87
+ declare function entitySupports(entity: Entity, capability: EntityCapability): boolean;
88
+ //#endregion
89
+ //#region src/diff/types.d.ts
90
+ /**
91
+ * Diff result types for version comparison.
92
+ */
93
+ /** A segment within a word-level text diff. */
94
+ interface DiffSegment {
95
+ type: 'equal' | 'add' | 'remove';
96
+ value: string;
97
+ }
98
+ /** Diff result for a single scalar field. */
99
+ interface FieldDiff {
100
+ field: string;
101
+ type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'media' | 'richtext' | 'json' | 'blockTree' | 'unknown';
102
+ from: unknown;
103
+ to: unknown;
104
+ /** Word-level diff segments (present for text/richtext/slug fields). */
105
+ segments?: DiffSegment[];
106
+ /** Block-level structural diff (present for `blockTree` fields). */
107
+ blockDiffs?: BlockDiffEntry[];
108
+ }
109
+ /** Status of a single block within a structural diff. */
110
+ type BlockDiffStatus = 'added' | 'removed' | 'moved' | 'changed' | 'unchanged';
111
+ /** Diff result for a single block within a `blockTree` field. */
112
+ interface BlockDiffEntry {
113
+ blockId: string;
114
+ blockType: string;
115
+ status: BlockDiffStatus;
116
+ fromIndex?: number | undefined;
117
+ toIndex?: number | undefined;
118
+ /** Per-prop diffs for changed blocks. */
119
+ propDiffs?: PropDiff[] | undefined;
120
+ }
121
+ /** Diff for a single prop within a changed block. */
122
+ interface PropDiff {
123
+ field: string;
124
+ from: unknown;
125
+ to: unknown;
126
+ /** Word-level segments for string props. */
127
+ segments?: DiffSegment[] | undefined;
128
+ }
129
+ //#endregion
130
+ //#region src/errors.d.ts
131
+ /**
132
+ * Domain errors for ContentClient + LockService.
133
+ */
134
+ /**
135
+ * Thrown when a caller invokes an entity-capability-specific method on an
136
+ * entity that doesn't have the corresponding behavior — e.g. `saveDraft`
137
+ * on a non-publishable entity, `setLocaleStatus` on an entity without
138
+ * publishable+translatable, `restoreVersion` on a non-versionable entity.
139
+ *
140
+ * Distinct from generic `Error` so admin-ui's API handler can map it to
141
+ * 400 Bad Request (caller's fault) rather than 500 (server bug).
142
+ */
143
+ declare class EntityCapabilityError extends Error {
144
+ readonly entityName: string;
145
+ readonly capability: 'drafts' | 'versions' | 'localeStatus' | 'translations';
146
+ constructor(entityName: string, capability: EntityCapabilityError['capability'], message?: string);
147
+ }
148
+ /**
149
+ * Thrown by `publishDraft` when there is no pending draft for the requested
150
+ * entity+locale — publishing is a promote-the-draft operation, so "nothing
151
+ * to promote" is a caller-state condition, not a server fault.
152
+ *
153
+ * Distinct from generic `Error` so admin-ui's API handler can map it to
154
+ * 409 Conflict with a human-readable message instead of a generic 500
155
+ * (which is what users saw pre-PR-23 — F031 #1).
156
+ */
157
+ declare class DraftNotFoundError extends Error {
158
+ readonly entityId: string;
159
+ readonly locale: string;
160
+ constructor(entityId: string, locale: string);
161
+ }
162
+ /**
163
+ * Thrown by `restoreVersion` when the requested version snapshot does not
164
+ * exist for the entity+locale — either the version number is out of range,
165
+ * or the entity row itself is gone (a deleted row has no versions).
166
+ *
167
+ * Distinct from generic `Error` so admin-ui's API handler can map it to
168
+ * 404 Not Found, matching the route-level 404 the publishable branch
169
+ * already returns for the same condition. Before this it fell through to
170
+ * the generic 500 branch on non-publishable entities — the same
171
+ * "caller-state condition audited as a server fault" defect as
172
+ * `EntityNotFoundError` (`plan/admin-api-hardening` F008).
173
+ */
174
+ declare class VersionNotFoundError extends Error {
175
+ readonly entityId: string;
176
+ readonly version: number;
177
+ readonly locale: string;
178
+ constructor(entityId: string, version: number, locale: string);
179
+ }
180
+ /**
181
+ * Thrown when a locale string passed at runtime is not configured in the
182
+ * content plugin. The plugin validates `defaultLocale` at init time, but
183
+ * the per-call `locale` parameter on every read/write is not naturally
184
+ * checked — `validateLocale()` is the boundary check.
185
+ *
186
+ * Allows the `'_'` sentinel (used internally for default-locale rows in
187
+ * versions/drafts/locale_status tables) so callers can pass it through.
188
+ */
189
+ declare class InvalidLocaleError extends Error {
190
+ readonly locale: string;
191
+ readonly allowed: readonly string[];
192
+ constructor(locale: string, allowed: readonly string[]);
193
+ }
194
+ //#endregion
195
+ //#region src/locale-registry.d.ts
196
+ /**
197
+ * The effective content-locale set — shipped defaults ⊕ DB overrides, sorted by
198
+ * `sortOrder`. Includes **disabled** locales (listable in the Languages editor);
199
+ * callers that want only switchable/valid locales use {@link enabledLocaleCodes}.
200
+ */
201
+ declare function resolveEffectiveLocales(app: ToolkitApp): Promise<LocaleEntry[]>;
202
+ /** The **enabled** codes from an effective set — the switcher + validateLocale whitelist. */
203
+ declare function enabledLocaleCodes(entries: readonly LocaleEntry[]): string[];
204
+ /**
205
+ * The resolved global i18n singletons (`i18n` settings ⊕ `content()` defaults).
206
+ * `defaultLocale` is guaranteed to be an **enabled** locale: the configured value
207
+ * wins if enabled, else the `content()` default if enabled, else the first enabled
208
+ * locale (last-resort, deterministic). Pass `entries` to reuse an already-resolved
209
+ * effective set (avoids a second registry read within one request).
210
+ */
211
+ declare function resolveI18nConfig(app: ToolkitApp, entries?: readonly LocaleEntry[]): Promise<I18nConfig>;
212
+ /** What {@link resolveSiteI18n} returns. */
213
+ interface SiteI18n {
214
+ /** The site resolved, or `undefined` when none could be named (see below). */
215
+ site: string | undefined;
216
+ /** The effective registry narrowed to the site (disabled locales kept). */
217
+ entries: LocaleEntry[];
218
+ /**
219
+ * The site's PRESENTATION defaults: the locale a switcher, a sitemap or an
220
+ * unlocalised request falls back to (an ENABLED locale of the site), and its
221
+ * routing strategy.
222
+ *
223
+ * ⚠️ Not the STORAGE default. Which locale a row's base columns hold is an
224
+ * install-wide fact (`content({ defaultLocale })`, `i18n.defaultLocale`) —
225
+ * shared rows (media) are written from every site, so it cannot vary by
226
+ * site. Read {@link install} for it.
227
+ */
228
+ i18n: I18nConfig;
229
+ /** The install-wide i18n singletons — the storage default. */
230
+ install: I18nConfig;
231
+ }
232
+ /**
233
+ * The effective registry narrowed to ONE SITE's locales (plan `multisite`
234
+ * phase 03) — the whitelist alone, without the i18n settings read, for the
235
+ * per-request callers (the admin API handler) that need only the set.
236
+ */
237
+ declare function resolveSiteLocales(app: ToolkitApp, options?: {
238
+ site?: string;
239
+ entries?: readonly LocaleEntry[];
240
+ }): Promise<{
241
+ site: string | undefined;
242
+ entries: LocaleEntry[];
243
+ }>;
244
+ /**
245
+ * The locale set and presentation defaults of ONE SITE (plan `multisite` D007,
246
+ * phase 03): the effective registry narrowed to the site record's `locales`
247
+ * (empty = every locale), with the site's own default and routing strategy.
248
+ *
249
+ * - `entries` keeps disabled locales (like {@link resolveEffectiveLocales});
250
+ * the whitelist is `enabledLocaleCodes(entries)`.
251
+ * - `i18n.defaultLocale` is an ENABLED locale of the site. On an app serving
252
+ * several sites the record's `defaultLocale` / `routingStrategy` win; on a
253
+ * single-site install the install's i18n settings stay authoritative (the
254
+ * record only holds a one-time copy of them, and the Languages editor writes
255
+ * the settings), so nothing about a one-site install changes.
256
+ * - The site is `options.site`, else the request's, else the single site of a
257
+ * one-site install. With none of those (a multi-site request that names no
258
+ * site) the install-wide set is returned — what a SHARED entity (media) edits
259
+ * against; a bound entity refuses on its own, without a site.
260
+ */
261
+ declare function resolveSiteI18n(app: ToolkitApp, options?: {
262
+ site?: string;
263
+ entries?: readonly LocaleEntry[];
264
+ }): Promise<SiteI18n>;
265
+ //#endregion
266
+ //#region src/message-registry.d.ts
267
+ /**
268
+ * The DB `message` override rows for `locale`, as flat `(namespace, key, value)` entries.
269
+ * Empty when the `message` entity isn't registered (no `messageEditor()` / pre-migration)
270
+ * or on any read error.
271
+ */
272
+ declare function resolveMessageOverrides(app: ToolkitApp, locale: string): Promise<MessageEntry[]>;
273
+ //#endregion
274
+ export { withOverrides as A, WorkflowableFields as C, OverridesMap as D, OverrideDirective as E, WithOverridesFields as O, WorkflowTransitionError as S, workflowable as T, EntityCapability as _, resolveI18nConfig as a, WORKFLOW_TRANSITIONS as b, DraftNotFoundError as c, VersionNotFoundError as d, BlockDiffEntry as f, PropDiff as g, FieldDiff as h, resolveEffectiveLocales as i, hasWithOverrides as k, EntityCapabilityError as l, DiffSegment as m, SiteI18n as n, resolveSiteI18n as o, BlockDiffStatus as p, enabledLocaleCodes as r, resolveSiteLocales as s, resolveMessageOverrides as t, InvalidLocaleError as u, entitySupports as v, isAllowedTransition as w, WorkflowState as x, WORKFLOW_STATES as y };
275
+ //# sourceMappingURL=index-cT8Qk4gu.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index-cT8Qk4gu.d.mts","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/diff/types.ts","../src/errors.ts","../src/locale-registry.ts","../src/message-registry.ts"],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import { a as LocaleDirection, c as LockInfo, d as TranslationReport, f as TranslationStatus, i as LocaleConfig, l as RoutingStrategy, m as VersionableOptions, n as DraftEntry, o as LocaleEntry, p as VersionEntry, r as I18nConfig, t as ContentConfig, u as TranslationFieldStatus } from "./types-BOk9tg_9.mjs";
2
+ import { n as getVersionableOptions, r as versionable } from "./versionable-CRvV87nZ.mjs";
3
+ import { A as withOverrides, C as WorkflowableFields, D as OverridesMap, E as OverrideDirective, O as WithOverridesFields, S as WorkflowTransitionError, T as workflowable, _ as EntityCapability, a as resolveI18nConfig, b as WORKFLOW_TRANSITIONS, c as DraftNotFoundError, d as VersionNotFoundError, f as BlockDiffEntry, g as PropDiff, h as FieldDiff, i as resolveEffectiveLocales, k as hasWithOverrides, l as EntityCapabilityError, m as DiffSegment, n as SiteI18n, o as resolveSiteI18n, p as BlockDiffStatus, r as enabledLocaleCodes, s as resolveSiteLocales, t as resolveMessageOverrides, u as InvalidLocaleError, v as entitySupports, w as isAllowedTransition, x as WorkflowState, y as WORKFLOW_STATES } from "./index-cT8Qk4gu.mjs";
4
+ import { a as ROUTING_STRATEGIES, i as Locale, n as prefixDefaultLocaleForStrategy, o as i18nSettings, r as Message, t as localePrefixForStrategy } from "./routing-strategy-CAG3tZP0.mjs";
5
+ export { type BlockDiffEntry, type BlockDiffStatus, type ContentConfig, type DiffSegment, type DraftEntry, DraftNotFoundError, type EntityCapability, EntityCapabilityError, type FieldDiff, type I18nConfig, InvalidLocaleError, Locale, type LocaleConfig, type LocaleDirection, type LocaleEntry, type LockInfo, Message, type OverrideDirective, type OverridesMap, type PropDiff, ROUTING_STRATEGIES, type RoutingStrategy, type SiteI18n, type TranslationFieldStatus, type TranslationReport, type TranslationStatus, type VersionEntry, VersionNotFoundError, type VersionableOptions, WORKFLOW_STATES, WORKFLOW_TRANSITIONS, type WithOverridesFields, type WorkflowState, WorkflowTransitionError, type WorkflowableFields, enabledLocaleCodes, entitySupports, getVersionableOptions, hasWithOverrides, i18nSettings, isAllowedTransition, localePrefixForStrategy, prefixDefaultLocaleForStrategy, resolveEffectiveLocales, resolveI18nConfig, resolveMessageOverrides, resolveSiteI18n, resolveSiteLocales, versionable, withOverrides, workflowable };
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import{n as e,t}from"./versionable-Br_G5VGR.mjs";import{i as n,n as r,r as i,t as a}from"./errors-NRNILhhm.mjs";import{n as o,r as s,t as c}from"./locale-entity-CoSq3C77.mjs";import{a as l,i as u,o as d,r as f,t as p}from"./locale-registry-COsJrVZl.mjs";import{n as m,r as h,t as g}from"./routing-strategy-D6B4ngT9.mjs";import{USER_ID_MAX_LENGTH as _,field as v}from"@murumets-ee/entity";import{createSystemReachResolver as y}from"@murumets-ee/core";import{QueryClient as b}from"@murumets-ee/entity/query";import{schemaRegistry as x}from"@murumets-ee/db";import{eq as S}from"drizzle-orm";const C=new Set([`hide`,`patch`,`replace`]),w=Symbol.for(`@murumets-ee/content:withOverrides`),T=new Set([`__proto__`,`constructor`,`prototype`]);function E(e,t,n=0){if(e!=null&&!(typeof e!=`object`||Array.isArray(e)))for(let r of Object.keys(e)){if(T.has(r))throw Error(`withOverrides: reserved key '${r}' at ${t} is not allowed.`);n<3&&E(e[r],`${t}.${r}`,n+1)}}function D(e){if(e==null)return{};if(typeof e!=`object`||Array.isArray(e))throw Error(`withOverrides: _overrides must be a plain object map.`);let t=e,n=Object.keys(t);if(n.length>100)throw Error(`withOverrides: _overrides has ${n.length} entries; cap is 100.`);let r={};for(let e of n){if(T.has(e))throw Error(`withOverrides: reserved key '${e}' is not allowed.`);let n=t[e];if(typeof n!=`object`||!n||Array.isArray(n))throw Error(`withOverrides: _overrides['${e}'] must be a directive object.`);let i=n.kind;if(typeof i!=`string`||!C.has(i))throw Error(`withOverrides: _overrides['${e}'].kind must be one of [hide, patch, replace]; got ${String(i)}.`);if(i===`patch`){let t=n;O(t.props,`_overrides['${e}'].props`),O(t.traits,`_overrides['${e}'].traits`)}if(i===`replace`){let t=n.block;if(typeof t!=`object`||!t||Array.isArray(t)||typeof t.id!=`string`)throw Error(`withOverrides: _overrides['${e}'] replace payload must include an object 'block' with a string 'id'.`)}E(n,`_overrides['${e}']`),r[e]=n}return r}function O(e,t){if(e!==void 0&&(typeof e!=`object`||!e||Array.isArray(e)))throw Error(`withOverrides: ${t} must be an object.`)}function k(){return{name:`withOverrides`,fields:{_overrides:v.json({default:()=>({}),internal:!0,refScan:!0})},hooks:{beforeCreate:async e=>(e._overrides===void 0?e._overrides={}:e._overrides=D(e._overrides),e),beforeUpdate:async(e,t)=>(t._overrides===void 0||(t._overrides=D(t._overrides)),t)},[w]:!0}}function A(e){return e?e.some(e=>!!e?.[w]):!1}const j=[`draft`,`in_review`,`approved`,`rejected`,`published`],M={draft:[`in_review`],in_review:[`approved`,`rejected`],approved:[`published`,`in_review`],rejected:[`in_review`,`draft`],published:[`draft`]};function N(e,t){return e===t?!0:M[e]?.includes(t)??!1}var P=class extends Error{from;to;constructor(e,t){super(`Invalid workflow transition: ${e} → ${t}. Allowed transitions from "${e}": [${(M[e]??[]).join(`, `)}].`),this.name=`WorkflowTransitionError`,this.from=e,this.to=t}};function F(){return{name:`workflowable`,fields:{_workflowStatus:v.select({options:j,default:`draft`,indexed:!0,required:!0,internal:!0}),_workflowAssignee:v.text({required:!1,internal:!0,maxLength:_}),_workflowNote:v.text({required:!1,internal:!0}),_workflowUpdatedAt:v.date({internal:!0})},hooks:{beforeCreate:async e=>(e._workflowStatus=`draft`,e._workflowUpdatedAt=new Date,e),beforeUpdate:async(e,t,n)=>{let r=t._workflowStatus;if(r===void 0)return t;if(!j.includes(r))throw new P(`draft`,r);let i=(await n?.loadCurrent?.())?._workflowStatus??`draft`,a=r;if(!N(i,a))throw new P(i,a);return t._workflowUpdatedAt=new Date,t}}}}function I(e,t){switch(t){case`drafts`:return L(e,`publishable`);case`versions`:return L(e,`versionable`);case`localeStatus`:return L(e,`publishable`)&&R(e);case`translations`:return R(e)}}function L(e,t){return e.behaviors?.some(e=>e.name===t)??!1}function R(e){let t=e.allFields;for(let e of Object.keys(t)){let n=t[e];if(n&&n.translatable===!0)return!0}return!1}const z=y(`message-registry`),B=2e4;async function V(e,t){if(!e.entities?.has(`message`))return[];let n=x.get(`message`);if(!n)return[];let r=new b({entity:h,db:e.db.readOnly,logger:e.logger.child({registry:`message`}),contextResolver:z});try{let i=await r.findMany({where:S(n.locale,t),limit:B});return i.length>=B&&e.logger.warn({locale:t,cap:B},`message override read hit the row cap — some overrides were not applied`),i.map(e=>{let t=e;return{namespace:String(t.namespace),key:String(t.key),value:String(t.value)}})}catch(n){return e.logger.warn({err:n,locale:t},`message registry read failed — falling back to shipped defaults only`),[]}}export{a as DraftNotFoundError,r as EntityCapabilityError,i as InvalidLocaleError,c as Locale,h as Message,o as ROUTING_STRATEGIES,n as VersionNotFoundError,j as WORKFLOW_STATES,M as WORKFLOW_TRANSITIONS,P as WorkflowTransitionError,p as enabledLocaleCodes,I as entitySupports,t as getVersionableOptions,A as hasWithOverrides,s as i18nSettings,N as isAllowedTransition,g as localePrefixForStrategy,m as prefixDefaultLocaleForStrategy,f as resolveEffectiveLocales,u as resolveI18nConfig,V as resolveMessageOverrides,l as resolveSiteI18n,d as resolveSiteLocales,e as versionable,k as withOverrides,F as workflowable};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/behaviors/with-overrides.ts","../src/behaviors/workflowable.ts","../src/capabilities.ts","../src/message-registry.ts"],"sourcesContent":["/**\n * `withOverrides()` — opt-in behavior wiring a document entity into the\n * page-scoped override resolver shipped by phase 05.\n *\n * Adds a single internal field `_overrides: Record<string, OverrideDirective>`\n * to the host entity, defaulted to an empty object. The composition step\n * (`composeChromeRegion` in `@murumets-ee/blocks`) reads this field and calls\n * `applyOverrides()` on each chrome region AFTER its blocks have been\n * scope-tagged.\n *\n * Apps opt-in:\n *\n * @example\n * ```ts\n * defineEntity({\n * name: 'page',\n * fields: { ... },\n * behaviors: [\n * publishable(),\n * versionable({ maxPerDoc: 30 }),\n * workflowable(),\n * withOpLog({ retentionDays: 7 }),\n * withOverrides(), // ← enables page-level overrides on scoped blocks\n * ],\n * })\n * ```\n *\n * Why a behavior (not a plain field):\n * - Discoverability — `entitySupports(entity, 'overrides')` lets the boot\n * orphan-sweep find every overrides-eligible entity without hard-coding\n * a list.\n * - Validation — the behavior's `beforeUpdate` hook validates the shape\n * of the `_overrides` map (Zod-equivalent runtime check) so a malformed\n * PATCH body can't poison the field.\n * - Defensive defaults — `_overrides: {}` is auto-seeded on `beforeCreate`\n * so consumers always see a value, never `undefined`.\n *\n * `internal: true` on the field keeps it out of HTTP PATCH input — the\n * `doc:scope-override:set` op is the only path that can mutate it (writes\n * are routed through `AdminClient.updateInternal()` after permission\n * gating).\n *\n * See `plan/blocks/05-scoping-overrides.md` §5 + `progress/findings/F009`.\n */\n\nimport type { Behavior, JsonField } from '@murumets-ee/entity'\nimport { field } from '@murumets-ee/entity'\n\n/**\n * Override-directive shape. Mirrored from `@murumets-ee/blocks` to avoid\n * a circular dep (content → blocks → content); content stores the\n * persisted JSON, blocks owns the resolver. Keep the two shapes in sync.\n */\ntype OverrideDirective =\n | { kind: 'hide' }\n | {\n kind: 'patch'\n props?: Record<string, unknown>\n traits?: Record<string, Record<string, unknown>>\n }\n | { kind: 'replace'; block: Record<string, unknown> }\n\ntype OverridesMap = Record<string, OverrideDirective>\n\nexport type WithOverridesFields = {\n // `required: false` at the type level — the `beforeCreate` hook\n // seeds the field with `{}` when callers omit it, so making the\n // INPUT optional is more ergonomic than threading `_overrides: {}`\n // through every consumer's create-input.\n //\n // NOTE: the DB column is NULLABLE in the generated migration\n // (`_overrides jsonb`) — the JsonField `default` is a runtime\n // function the entity layer calls on create, NOT a SQL DEFAULT\n // clause the migration generator sees. Readers MUST treat NULL\n // as equivalent to `{}` (see `compose-chrome.ts`'s `asOverridesMap`\n // for the canonical pattern). A row inserted\n // via raw SQL or migration that bypasses the entity layer can\n // leave NULL; that's safe.\n _overrides: JsonField\n}\n\n/**\n * Allowed directive kinds — closed set. Unknown discriminators are rejected\n * at the behavior boundary so a malformed PATCH (or future plugin)\n * can't write opaque shapes that the resolver silently ignores.\n */\nconst ALLOWED_KINDS: ReadonlySet<string> = new Set(['hide', 'patch', 'replace'])\n\n/**\n * Maximum number of override entries per document. Bounded to keep the\n * page row's JSON column compact + the rendering hot path predictable.\n * Per phase 05 §9: a page overriding more than ~50 scoped blocks is a\n * smell — at that point the shared chrome is the wrong chrome for that\n * page, and the answer is a different `site_chrome` row or page-local blocks,\n * not more patching.\n */\nconst MAX_OVERRIDES_PER_DOC = 100\n\n/** Marker symbol so introspection can pick out the behavior reliably. */\nconst WITH_OVERRIDES_MARKER = Symbol.for('@murumets-ee/content:withOverrides')\n\ninterface WithOverridesMarker {\n [WITH_OVERRIDES_MARKER]: true\n}\n\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\n\n/**\n * Walk a plain-object value's OWN keys and reject any that match a\n * prototype-pollution vector. JSON.parse can create own properties\n * with these names; the dispatcher already filters at the route +\n * op-handler boundary, but the behavior is the third layer of\n * defense (any persistence path that bypasses the dispatcher — DB\n * seed, raw SQL migration, future internal API — lands here on the\n * next update).\n *\n * The depth cap of 3 covers every documented over-the-wire shape:\n *\n * - patch.props.__proto__ → depth 1\n * - patch.traits.spacing.__proto__ → depth 2\n * - replace.block.props.__proto__ → depth 2\n * - replace.block.traits.spacing.__proto__ → depth 3\n *\n * (Counting from the directive root at depth 0. The recursion CHECKS\n * keys at the entered depth, then recurses while `depth < 3`, so the\n * deepest check happens at depth=3.)\n *\n * Beyond that depth the structure is field VALUES which are caller-\n * payload (block field type / trait field type) — those are the\n * field-type validator's job, not the override-map gate.\n *\n * `path` is appended to the thrown error for triage.\n */\nfunction assertNoForbiddenKeys(value: unknown, path: string, depth = 0): void {\n if (value === null || value === undefined) return\n if (typeof value !== 'object' || Array.isArray(value)) return\n for (const key of Object.keys(value as Record<string, unknown>)) {\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' at ${path} is not allowed.`)\n }\n if (depth < 3) {\n assertNoForbiddenKeys((value as Record<string, unknown>)[key], `${path}.${key}`, depth + 1)\n }\n }\n}\n\n/**\n * Validate an override-map payload. Throws when malformed; returns the\n * (defensively-cloned, sanitised) value when ok. Called from the\n * `beforeUpdate` hook — `internal: true` blocks PATCH-side input but\n * the `doc:scope-override:set` op writes via `updateInternal` and so\n * still flows through here.\n *\n * Defense layered: outer scope-block-id keys + each directive's\n * recursive nested keys (patch.props / patch.traits / replace.block.props\n * / replace.block.traits) all walk through `assertNoForbiddenKeys`. The\n * dispatcher's own handler validates the same set; this is the\n * defense-in-depth replay at the persistence boundary.\n */\nfunction validateOverridesMap(value: unknown): OverridesMap {\n if (value === null || value === undefined) return {}\n if (typeof value !== 'object' || Array.isArray(value)) {\n throw new Error('withOverrides: _overrides must be a plain object map.')\n }\n const map = value as Record<string, unknown>\n const keys = Object.keys(map)\n if (keys.length > MAX_OVERRIDES_PER_DOC) {\n throw new Error(\n `withOverrides: _overrides has ${keys.length} entries; cap is ${MAX_OVERRIDES_PER_DOC}.`,\n )\n }\n const sanitised: OverridesMap = {}\n for (const key of keys) {\n // Skip prototype-pollution vectors at the OUTER (scopeBlockId)\n // key boundary first.\n if (FORBIDDEN_KEYS.has(key)) {\n throw new Error(`withOverrides: reserved key '${key}' is not allowed.`)\n }\n const directive = map[key]\n if (\n directive === null ||\n directive === undefined ||\n typeof directive !== 'object' ||\n Array.isArray(directive)\n ) {\n throw new Error(`withOverrides: _overrides['${key}'] must be a directive object.`)\n }\n const kind = (directive as { kind?: unknown }).kind\n if (typeof kind !== 'string' || !ALLOWED_KINDS.has(kind)) {\n throw new Error(\n `withOverrides: _overrides['${key}'].kind must be one of [hide, patch, replace]; got ${String(\n kind,\n )}.`,\n )\n }\n // Kind-specific shape enforcement (CR bot major) — `kind` alone\n // isn't enough; a malformed `{ kind: 'replace' }` (no block) or\n // `{ kind: 'patch', props: 123 }` would otherwise pass the gate\n // and land invalid data in persistence. Mirror the dispatcher\n // handler's shape checks at this third defense layer.\n if (kind === 'patch') {\n const d = directive as { props?: unknown; traits?: unknown }\n assertPlainObjectOrUndefined(d.props, `_overrides['${key}'].props`)\n assertPlainObjectOrUndefined(d.traits, `_overrides['${key}'].traits`)\n }\n if (kind === 'replace') {\n const block = (directive as { block?: unknown }).block\n if (\n block === null ||\n block === undefined ||\n typeof block !== 'object' ||\n Array.isArray(block) ||\n typeof (block as { id?: unknown }).id !== 'string'\n ) {\n throw new Error(\n `withOverrides: _overrides['${key}'] replace payload must include an object 'block' with a string 'id'.`,\n )\n }\n }\n // Recurse into the directive payload — patch.props / patch.traits\n // / replace.block.props / replace.block.traits — and reject any\n // forbidden own-property keys at every level.\n assertNoForbiddenKeys(directive, `_overrides['${key}']`)\n sanitised[key] = directive as OverrideDirective\n }\n return sanitised\n}\n\n/**\n * Reject a directive sub-field that's present but not a plain object\n * (array / primitive / null). `undefined` is allowed — the field is\n * optional. Mirrors the dispatcher handler's `assertPlainObjectOrUndefined`.\n */\nfunction assertPlainObjectOrUndefined(value: unknown, label: string): void {\n if (value === undefined) return\n if (value === null || typeof value !== 'object' || Array.isArray(value)) {\n throw new Error(`withOverrides: ${label} must be an object.`)\n }\n}\n\n/**\n * Mark an entity as participating in the page-scoped override resolver.\n * See module-level JSDoc for the full contract.\n */\nexport function withOverrides(): Behavior<WithOverridesFields, 'withOverrides'> {\n const behavior: Behavior<WithOverridesFields, 'withOverrides'> & WithOverridesMarker = {\n name: 'withOverrides',\n fields: {\n // `internal: true` blocks PATCH-input poisoning (the\n // `doc:scope-override:set` op writes via updateInternal which\n // bypasses the input filter). `default: () => ({})` seeds the\n // value on every create call, so callers can omit `_overrides`\n // entirely (no `required: true` needed at the input level).\n // `refScan: true` (images PR16): replacement blocks in the map hold media\n // refs — with a placement's own spot crop — exactly as the block tree\n // does, so the usage index records them. Without it an image placed only\n // in an override was used NOWHERE as far as delete protection and the\n // spot-crop sweep could tell, and the sweep would collect the files of a\n // crop a published page still shows.\n _overrides: field.json({\n default: () => ({}),\n internal: true,\n refScan: true,\n }),\n },\n hooks: {\n beforeCreate: async (data) => {\n // Seed the empty map on create so the column is never null in DB.\n // Caller-supplied `_overrides` on create is unusual (overrides\n // are typically dispatched op-by-op) but if they DO pass one,\n // validate it.\n if (data._overrides === undefined) {\n data._overrides = {}\n } else {\n data._overrides = validateOverridesMap(data._overrides)\n }\n return data\n },\n beforeUpdate: async (_id, data) => {\n // Only validate when the patch tries to write `_overrides`.\n if (data._overrides === undefined) return data\n data._overrides = validateOverridesMap(data._overrides)\n return data\n },\n },\n [WITH_OVERRIDES_MARKER]: true,\n }\n return behavior\n}\n\n/**\n * Check whether an entity declares `withOverrides()`. Returns `true`\n * when the entity's behaviors list contains a behavior tagged with\n * the marker symbol. Used by the boot orphan-sweep + admin UI gating.\n */\nexport function hasWithOverrides(\n behaviors: ReadonlyArray<{ name: string } & Record<symbol, unknown>> | undefined,\n): boolean {\n if (!behaviors) return false\n return behaviors.some((b) => Boolean(b?.[WITH_OVERRIDES_MARKER]))\n}\n\n/**\n * Export the directive type alias for downstream consumers that want\n * to type-narrow without depending on `@murumets-ee/blocks`. The\n * resolver lives in blocks; the persisted shape lives here.\n */\nexport type { OverrideDirective, OverridesMap }\n","/**\n * Workflowable behavior — editorial workflow with approval gates.\n *\n * Adds workflow status tracking fields to an entity and enforces the\n * documented transition state machine in `beforeUpdate`. Works alongside\n * `publishable()` — the workflow status tracks the editorial pipeline,\n * while publishable's `status` remains the public-facing state.\n *\n * Workflow state machine:\n *\n * draft ─submit─► in_review ─approve─► approved ─publish─► published\n * │ ▲ │\n * │ └────resubmit─────┐ │\n * └─reject─► rejected ─┘ │\n * │\n * unpublish ◄──────────────┘\n * (back to draft)\n *\n * Internal-write only: every workflow field is declared `internal: true`,\n * so the HTTP PATCH surface strips them from caller input. Workflow\n * transitions are performed by trusted server-side routes via\n * `AdminClient.updateInternal()` after authorizing the action with the\n * permission checker. The `beforeUpdate` hook validates that the\n * proposed transition is in the allowed table.\n *\n * Permission gating lives in the route handler (admin-ui workflow\n * routes), not in this behavior — by the time the hook fires, the\n * route has already authorized via `checkPermission`. The hook is\n * the structural defense: even a bug in the route layer cannot push\n * a workflow row into an illegal state.\n *\n * UI integration is tracked in GitHub issue #124. Until the workflow\n * UX ships, the behavior can still be added to entities — it'll simply\n * seed `_workflowStatus = 'draft'` on create and reject anyone trying\n * to write the field via PATCH.\n */\n\nimport type {\n Behavior,\n BehaviorContext,\n DateField,\n SelectField,\n TextField,\n} from '@murumets-ee/entity'\nimport { field, USER_ID_MAX_LENGTH } from '@murumets-ee/entity'\n\nexport const WORKFLOW_STATES = ['draft', 'in_review', 'approved', 'rejected', 'published'] as const\nexport type WorkflowState = (typeof WORKFLOW_STATES)[number]\n\n/**\n * Allowed transitions table. Each key is the *current* workflow status;\n * the value is the set of statuses it can move to.\n *\n * Encoded as a const-asserted record so a typo in the source produces\n * a compile error rather than a silent no-match at runtime.\n */\nexport const WORKFLOW_TRANSITIONS = {\n draft: ['in_review'],\n in_review: ['approved', 'rejected'],\n approved: ['published', 'in_review'],\n rejected: ['in_review', 'draft'],\n published: ['draft'],\n} as const satisfies Record<WorkflowState, readonly WorkflowState[]>\n\n/**\n * Is `to` an allowed transition from `from`? Pure, no I/O.\n */\nexport function isAllowedTransition(from: WorkflowState, to: WorkflowState): boolean {\n if (from === to) return true // no-op self-transition is fine\n const allowed = WORKFLOW_TRANSITIONS[from] as readonly WorkflowState[] | undefined\n return allowed?.includes(to) ?? false\n}\n\n/**\n * Thrown when `beforeUpdate` rejects a workflow transition. Distinct\n * subclass so admin-ui's API handler can map it to HTTP 400.\n */\nexport class WorkflowTransitionError extends Error {\n readonly from: WorkflowState\n readonly to: WorkflowState\n\n constructor(from: WorkflowState, to: WorkflowState) {\n super(\n `Invalid workflow transition: ${from} → ${to}. ` +\n `Allowed transitions from \"${from}\": [${(WORKFLOW_TRANSITIONS[from] ?? []).join(', ')}].`,\n )\n this.name = 'WorkflowTransitionError'\n this.from = from\n this.to = to\n }\n}\n\nexport type WorkflowableFields = {\n _workflowStatus: SelectField & { required: true }\n _workflowAssignee: TextField\n _workflowNote: TextField\n _workflowUpdatedAt: DateField\n}\n\nexport function workflowable(): Behavior<WorkflowableFields> {\n return {\n name: 'workflowable',\n fields: {\n _workflowStatus: field.select({\n options: WORKFLOW_STATES as unknown as string[],\n default: 'draft',\n indexed: true,\n required: true,\n internal: true,\n }) as SelectField & { required: true },\n // `_workflowAssignee` stores a better-auth user ID — varchar(255)\n // per CLAUDE.md §\"User IDs\", emitted via `maxLength: 255` (#430).\n // `_workflowNote` is free-text, so it stays unbounded `text`.\n _workflowAssignee: field.text({\n required: false,\n internal: true,\n maxLength: USER_ID_MAX_LENGTH,\n }),\n _workflowNote: field.text({ required: false, internal: true }),\n _workflowUpdatedAt: field.date({ internal: true }),\n },\n hooks: {\n beforeCreate: async (data) => {\n data._workflowStatus = 'draft'\n data._workflowUpdatedAt = new Date()\n return data\n },\n beforeUpdate: async (\n _id: string,\n data: Record<string, unknown>,\n ctx?: BehaviorContext,\n ): Promise<Record<string, unknown>> => {\n // Only validate when the patch tries to change the workflow status.\n // Updates that don't touch _workflowStatus pass through (the field's\n // `internal: true` already prevents it from sneaking in via PATCH;\n // only updateInternal callers reach this branch with a value).\n const proposed = data._workflowStatus\n if (proposed === undefined) return data\n\n if (!WORKFLOW_STATES.includes(proposed as WorkflowState)) {\n throw new WorkflowTransitionError('draft', proposed as WorkflowState)\n }\n\n // Need the current row to validate the from→to transition. The\n // lazy loader is provided by AdminClient — present on update,\n // absent on create.\n const current = await ctx?.loadCurrent?.()\n const fromState = (current?._workflowStatus as WorkflowState | undefined) ?? 'draft'\n const toState = proposed as WorkflowState\n\n if (!isAllowedTransition(fromState, toState)) {\n throw new WorkflowTransitionError(fromState, toState)\n }\n\n data._workflowUpdatedAt = new Date()\n return data\n },\n },\n }\n}\n","/**\n * Per-entity content capability gates.\n *\n * The content plugin attaches different infrastructure tables to an\n * entity based on which behaviors it has:\n * - `publishable()` → drafts table\n * - `versionable()` → versions table\n * - `publishable() + translatable fields` → locale_status table\n * - any translatable field → translations table (also written by the\n * entity package; included for symmetric capability checks)\n *\n * Calling a capability-specific ContentClient method (`saveDraft`,\n * `publishDraft`, `setLocaleStatus`, `restoreVersion`, ...) on an\n * entity that doesn't have the required behavior is a programming\n * error. ContentClient throws `EntityCapabilityError` on writes and\n * silently returns falsy on reads. Callers that want to gate ahead —\n * \"should I show the draft button on this entity row?\" — use\n * `entitySupports()` instead of catching the error.\n */\n\nimport type { Entity, FieldConfig } from '@murumets-ee/entity'\n\nexport type EntityCapability = 'drafts' | 'versions' | 'localeStatus' | 'translations'\n\n/**\n * Does this entity support the given content capability? Pure check\n * against the entity's declared behaviors and field translatability —\n * no plugin lookup, no DB call.\n */\nexport function entitySupports(entity: Entity, capability: EntityCapability): boolean {\n switch (capability) {\n case 'drafts':\n return hasBehavior(entity, 'publishable')\n case 'versions':\n return hasBehavior(entity, 'versionable')\n case 'localeStatus':\n return hasBehavior(entity, 'publishable') && hasTranslatableField(entity)\n case 'translations':\n return hasTranslatableField(entity)\n }\n}\n\nfunction hasBehavior(entity: Entity, name: string): boolean {\n return entity.behaviors?.some((b) => b.name === name) ?? false\n}\n\nfunction hasTranslatableField(entity: Entity): boolean {\n const fields = entity.allFields as Record<string, FieldConfig>\n for (const key of Object.keys(fields)) {\n const f = fields[key]\n if (f && (f as { translatable?: boolean }).translatable === true) return true\n }\n return false\n}\n","/**\n * The interface-message **registry resolver** (plan/config phase 06, D004/D010).\n *\n * The effective interface strings = **read-time-merge** of the per-package / per-app\n * message JSON defaults ⊕ the DB `message` config-entity override rows. This resolver\n * returns the OVERRIDE rows for one locale as flat {@link MessageEntry}s; the caller\n * (the admin `i18n/request.ts`) nests them via `overridesToCatalog` and passes the\n * result as the last `mergeMessages(...)` argument so DB overrides win — live edits,\n * no rebuild.\n *\n * The read is a **system-context** query on the read-only connection: interface strings\n * must reflect customizations on EVERY request (incl. the unauthenticated login page's\n * `AuthUI` strings), exactly like the app-wide locale/roles reads. It degrades to \"no\n * overrides\" (shipped defaults only) on any error — a fresh app pre-migration or a\n * transient DB blip simply renders the shipped strings.\n */\n\nimport { createSystemReachResolver, type ToolkitApp } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport type { MessageEntry } from '@murumets-ee/i18n'\nimport { eq } from 'drizzle-orm'\nimport { Message } from './message-entity.js'\n\n/**\n * Elevated **reach** resolver for the app-wide message read (the locale/roles-read\n * analog). Grants `view` and nothing else: this module only READS override rows,\n * and the editor route that writes them is separately gated. Named constructor\n * rather than a `checker: () => true` literal so the identity is enumerable —\n * see the elevation allowlist in `scripts/verify-package-boundaries.mjs`.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('message-registry')\n\n/**\n * Defensive upper bound on override rows read for one locale. A fully-translated app\n * across several locales legitimately accumulates thousands of rows, but per-locale the\n * count is bounded by the number of *customized* strings. The read is `log()`-ged when it\n * hits the cap so a silent truncation can't read as \"all overrides applied\" (CLAUDE.md\n * no-unbounded-fan-out rule).\n */\nconst MAX_MESSAGE_OVERRIDE_ROWS = 20000\n\n/**\n * The DB `message` override rows for `locale`, as flat `(namespace, key, value)` entries.\n * Empty when the `message` entity isn't registered (no `messageEditor()` / pre-migration)\n * or on any read error.\n */\nexport async function resolveMessageOverrides(\n app: ToolkitApp,\n locale: string,\n): Promise<MessageEntry[]> {\n if (!app.entities?.has('message')) return []\n const table = schemaRegistry.get('message')\n if (!table) return []\n\n const client = new QueryClient({\n entity: Message,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'message' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n\n try {\n const rows = await client.findMany({\n where: eq(table.locale, locale),\n limit: MAX_MESSAGE_OVERRIDE_ROWS,\n })\n if (rows.length >= MAX_MESSAGE_OVERRIDE_ROWS) {\n app.logger.warn(\n { locale, cap: MAX_MESSAGE_OVERRIDE_ROWS },\n 'message override read hit the row cap — some overrides were not applied',\n )\n }\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n namespace: String(row.namespace),\n key: String(row.key),\n value: String(row.value),\n }\n })\n } catch (err) {\n app.logger.warn(\n { err, locale },\n 'message registry read failed — falling back to shipped defaults only',\n )\n return []\n }\n}\n"],"mappings":"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"}
@@ -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";const a=[`prefix-all`,`no-prefix-default`,`cookie`],o=r({namespace:`i18n`,scope:`global`,label:`Languages`,iconName:`languages`,hideFromMenu:!0,schema:{defaultLocale:i.text({default:``}),routingStrategy:i.select({options:a,default:`no-prefix-default`})}}),s=t({name:`locale`,class:`config`,scope:`global`,site:`shared`,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`}});export{a as n,o as r,s as t};
2
+ //# sourceMappingURL=locale-entity-CoSq3C77.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-entity-CoSq3C77.mjs","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts"],"sourcesContent":["/**\n * `i18n` — the global i18n singletons **simple-config** namespace (plan/config\n * phase 05 §3, D002/D009/SD006): `defaultLocale` + `routingStrategy`.\n *\n * Simple config (D002) rather than a config entity because these are singletons,\n * not a list. Contributed via the content plugin's `shared.settings` slot, so it\n * auto-registers as a config type (D006) and rides export/import (phase 09) for\n * free. Edited through the bespoke **Languages** editor (`hideFromMenu`), which\n * also enforces `defaultLocale ∈ enabled locales`.\n *\n * Imports from `@murumets-ee/settings/define` (the pure builder subpath, no\n * `server-only`) so the content plugin stays jiti-safe at config-load time.\n */\n\nimport { defineSettings, setting } from '@murumets-ee/settings/define'\n\n/** The three URL locale-routing strategies (D009). */\nexport const ROUTING_STRATEGIES = ['prefix-all', 'no-prefix-default', 'cookie'] as const\n\nexport const i18nSettings = defineSettings({\n namespace: 'i18n',\n scope: 'global',\n label: 'Languages',\n iconName: 'languages',\n // Edited via the Languages editor, not the generic per-namespace settings page.\n hideFromMenu: true,\n schema: {\n /**\n * The site-wide default content locale. Empty ⇒ inherit the `content()`\n * shipped `defaultLocale`. The resolver ({@link resolveI18nConfig}) validates\n * it is an **enabled** locale and falls back to the `content()` default if not.\n */\n defaultLocale: setting.text({ default: '' }),\n /**\n * How the public URL encodes locale. Maps to next-intl `localePrefix` /\n * Astro `prefixDefaultLocale` via `./routing-strategy.js`.\n */\n routingStrategy: setting.select({\n options: ROUTING_STRATEGIES,\n default: 'no-prefix-default',\n }),\n },\n})\n","/**\n * `locale` — the content-locale registry **config entity** (plan/config phase 05\n * §3, D006/D009/SD006).\n *\n * One row per content locale the operator adds or customizes. The row is a\n * **read-time-merge override** (D004): a row whose `code` matches a `content()`\n * shipped default replaces that default (relabel / set direction / reorder /\n * `enabled: false` to hide it); a row with a new `code` adds a locale. Locales\n * with no row are the shipped defaults. See {@link resolveEffectiveLocales}.\n *\n * `class: 'config'` (SD001) marks it a configuration entity: it auto-registers\n * into the `ConfigTypeRegistry` (phase-09 export/import picks it up for free),\n * defaults to audit-only (no publish / no trash — trash auto-enable skips config\n * entities), and is edited through the bespoke **Languages** editor, not the\n * generic auto-CRUD (`admin.hidden`).\n *\n * `access` is admin-only: managing the language set is an admin capability. The\n * app-wide *effective-locale read* ({@link resolveEffectiveLocales}) uses a\n * system-context query so every role still resolves valid locales for editing\n * content, without being able to list/edit the registry itself.\n *\n * Leaf-safe: imports only `@murumets-ee/entity`, so the content plugin can pull\n * it in without a `server-only` chain through the jiti config load.\n */\n\nimport { behavior, defineEntity, field } from '@murumets-ee/entity'\n\nexport const Locale = defineEntity({\n name: 'locale',\n class: 'config',\n scope: 'global',\n // The locale registry is global; each site lists the subset it serves (D007).\n site: 'shared',\n access: {\n view: 'locale:view',\n create: 'locale:create',\n update: 'locale:update',\n delete: 'locale:delete',\n },\n fields: {\n /**\n * BCP47 locale code (e.g. `en`, `et`, `pt-BR`). Unique — one row per locale.\n * Capped at 10 to match the `varchar(10)` locale columns in the\n * versions / drafts / locale_status tables (truncation past 10 would break\n * later equality — see `locale-utils.ts`).\n */\n code: field.text({ required: true, unique: true, maxLength: 10 }),\n /** Human label shown in the switcher + editor (e.g. `English`). */\n label: field.text({ required: true, maxLength: 64 }),\n /**\n * When `false` the locale is hidden from the content switcher + rejected by\n * `validateLocale` (the D004 disable-not-delete edge), but stays listable in\n * the Languages editor so it can be re-enabled.\n */\n enabled: field.boolean({ default: true }),\n /** Text direction (`ltr`/`rtl`) — persisted per locale for RTL-aware surfaces to read\n * when setting `dir`; the surface-level `dir` wiring lands with RTL support. */\n direction: field.select({ options: ['ltr', 'rtl'] as const, default: 'ltr' }),\n /** Display order in the switcher + editor (ascending). */\n sortOrder: field.number({ default: 0 }),\n },\n behaviors: [behavior.auditable()],\n admin: {\n // Edited via the Languages editor, not the generic CRUD — keep it out of the\n // auto entity list + sidebar.\n hidden: true,\n label: 'Languages',\n labelSingular: 'Language',\n },\n})\n"],"mappings":"uJAiBA,MAAa,EAAqB,CAAC,aAAc,oBAAqB,QAAQ,EAEjE,EAAe,EAAe,CACzC,UAAW,OACX,MAAO,SACP,MAAO,YACP,SAAU,YAEV,aAAc,GACd,OAAQ,CAMN,cAAe,EAAQ,KAAK,CAAE,QAAS,EAAG,CAAC,EAK3C,gBAAiB,EAAQ,OAAO,CAC9B,QAAS,EACT,QAAS,mBACX,CAAC,CACH,CACF,CAAC,ECfY,EAAS,EAAa,CACjC,KAAM,SACN,MAAO,SACP,MAAO,SAEP,KAAM,SACN,OAAQ,CACN,KAAM,cACN,OAAQ,gBACR,OAAQ,gBACR,OAAQ,eACV,EACA,OAAQ,CAON,KAAM,EAAM,KAAK,CAAE,SAAU,GAAM,OAAQ,GAAM,UAAW,EAAG,CAAC,EAEhE,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,EAAG,CAAC,EAMnD,QAAS,EAAM,QAAQ,CAAE,QAAS,EAAK,CAAC,EAGxC,UAAW,EAAM,OAAO,CAAE,QAAS,CAAC,MAAO,KAAK,EAAY,QAAS,KAAM,CAAC,EAE5E,UAAW,EAAM,OAAO,CAAE,QAAS,CAAE,CAAC,CACxC,EACA,UAAW,CAAC,EAAS,UAAU,CAAC,EAChC,MAAO,CAGL,OAAQ,GACR,MAAO,YACP,cAAe,UACjB,CACF,CAAC"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{n as t,r as n,t as r}from"./locale-entity-CoSq3C77.mjs";import{t as i}from"./content-config-store-8omQF5p5.mjs";import{createSystemReachResolver as a,getApp as o,getCurrentSite as s,mergeConfigOverrides as c,readSiteRecord as l,resolveImplicitSite as u}from"@murumets-ee/core";import{QueryClient as d}from"@murumets-ee/entity/query";var f=e({enabledLocaleCodes:()=>v,resolveEffectiveLocales:()=>_,resolveI18nConfig:()=>y,resolveInstallBaseLocale:()=>T,resolveSiteI18n:()=>w,resolveSiteLocales:()=>C});const p=a(`locale-registry`);function m(){return i().locales.map((e,t)=>({code:e.code,label:e.label,enabled:!0,direction:`ltr`,sortOrder:t}))}function h(e,t){return e.sortOrder-t.sortOrder||e.code.localeCompare(t.code)}async function g(e){return e.entities?.has(`locale`)?(await new d({entity:r,db:e.db.readOnly,logger:e.logger.child({registry:`locale`}),contextResolver:p}).findMany({limit:1e3})).map(e=>{let t=e;return{code:String(t.code),label:typeof t.label==`string`&&t.label.length>0?t.label:String(t.code),enabled:t.enabled!==!1,direction:t.direction===`rtl`?`rtl`:`ltr`,sortOrder:typeof t.sortOrder==`number`?t.sortOrder:0}}):[]}async function _(e){let t;try{t=await g(e)}catch(t){return e.logger.warn({err:t},`locale registry read failed — falling back to content() defaults`),[...m()].sort(h)}return c({defaults:m(),overrides:t,idOfDefault:e=>e.code,idOfOverride:e=>e.code}).sort(h)}function v(e){return e.filter(e=>e.enabled).map(e=>e.code)}async function y(e,r){let a=v(r??await _(e)),o=i().defaultLocale,s={};try{let{createSettingsClient:t}=await import(`@murumets-ee/settings/runtime`);s=await t(n,{app:e}).getAll()??{}}catch(t){e.logger.warn({err:t},`i18n settings read failed — falling back to content() defaults`)}let c=typeof s.defaultLocale==`string`&&s.defaultLocale.length>0?s.defaultLocale:o,l=a.includes(c)?c:a.includes(o)?o:a[0]??o,u=s.routingStrategy;return{defaultLocale:l,routingStrategy:t.includes(typeof u==`string`?u:``)?u:`no-prefix-default`}}async function b(e,t){return t??s()??await u(e).catch(()=>void 0)}function x(e,t){return l(e,t)}function S(e,t){let n=t?.locales??[];return n.length===0?e:e.filter(e=>n.includes(e.code))}async function C(e,t={}){let n=[...t.entries??await _(e)],r=await b(e,t.site);return r===void 0?{site:r,entries:n}:{site:r,entries:S(n,await x(e,r))}}async function w(e,n={}){let r=[...n.entries??await _(e)],i=await y(e,r),a=await b(e,n.site);if(a===void 0)return{site:a,entries:r,i18n:i,install:i};let o=await x(e,a),s=S(r,o),c=v(s),l=e.config.sites?.multiple===!0,u=l?o?.defaultLocale:void 0,d=u!==void 0&&c.includes(u)?u:c.includes(i.defaultLocale)?i.defaultLocale:c[0]??i.defaultLocale,f=l?o?.routingStrategy:void 0;return{site:a,entries:s,i18n:{defaultLocale:d,routingStrategy:t.includes(f??``)?f:i.routingStrategy},install:i}}async function T(){let e=o();if(e.entities.has(`locale`))return(await y(e)).defaultLocale}export{w as a,y as i,f as n,C as o,_ as r,v as t};
2
+ //# sourceMappingURL=locale-registry-COsJrVZl.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-registry-COsJrVZl.mjs","names":[],"sources":["../src/locale-registry.ts"],"sourcesContent":["/**\n * The content-locale **registry resolvers** (plan/config phase 05 §7, D004/D009/SD006).\n *\n * The effective content-locale set = **read-time-merge** of the `content()` shipped\n * defaults ⊕ the DB `locale` config-entity override rows. Resolved **once per request**\n * at context establishment (the admin API handler + `withAdminContext`) and threaded\n * onto {@link RequestContext.availableLocales}, so `validateLocale`'s synchronous\n * whitelist reflects runtime-added / disabled locales with **no rebuild and no\n * globalThis cache** — multi-process / multi-tenant correct.\n *\n * The registry read is a **system-context** query: managing the language set is\n * admin-only (`locale:*`), but every role must resolve valid locales to edit content,\n * exactly like the app-wide roles read. Reads degrade to the static `content()`\n * defaults on any error (fresh app pre-migration, transient DB failure) — deny-by-\n * default is preserved (a not-yet-known locale simply isn't whitelisted).\n */\n\nimport {\n createSystemReachResolver,\n getApp,\n getCurrentSite,\n mergeConfigOverrides,\n readSiteRecord,\n resolveImplicitSite,\n type SiteRecordView,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { getContentConfig } from './content-config-store.js'\nimport { i18nSettings, ROUTING_STRATEGIES } from './i18n-settings.js'\nimport { Locale } from './locale-entity.js'\nimport type { I18nConfig, LocaleDirection, LocaleEntry, RoutingStrategy } from './types.js'\n\n/**\n * Elevated **reach** resolver for the app-wide registry read (the roles-read\n * analog). It grants `view` and nothing else — this module only ever READS the\n * `locale` rows, so an all-allow checker would have handed every caller of this\n * resolver a write grant it has no use for. The named constructor owns that\n * exception centrally rather than leaving it to be re-derived per call site;\n * a hand-written `checker: () => true` here is refused by `verify:boundaries`.\n *\n * The explicit id is the audit identity — two elevated readers sharing the\n * default `'system'` are indistinguishable in the log.\n */\nconst SYSTEM_CONTEXT = createSystemReachResolver('locale-registry')\n\n/** The `content()`-shipped locales projected to full {@link LocaleEntry}s (all enabled). */\nfunction shippedDefaults(): LocaleEntry[] {\n return getContentConfig().locales.map((l, i) => ({\n code: l.code,\n label: l.label,\n enabled: true,\n direction: 'ltr' as LocaleDirection,\n sortOrder: i,\n }))\n}\n\n/** Ascending by `sortOrder`, then `code` for a stable tie-break. */\nfunction bySortOrder(a: LocaleEntry, b: LocaleEntry): number {\n return a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)\n}\n\n/**\n * Defensive upper bound on `locale` rows to read (a tenant realistically has <20). The\n * Languages-editor route MUST reconcile against the SAME bound so it can't miss/strand\n * rows — see admin-ui `locale-routes` `MAX_EXISTING_LOCALE_ROWS` (kept in sync).\n */\nconst MAX_LOCALE_ROWS = 1000\n\n/** Read + normalize the DB `locale` override rows (system context). */\nasync function readLocaleRows(app: ToolkitApp): Promise<LocaleEntry[]> {\n if (!app.entities?.has('locale')) return []\n const client = new QueryClient({\n entity: Locale,\n db: app.db.readOnly,\n logger: app.logger.child({ registry: 'locale' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n const rows = await client.findMany({ limit: MAX_LOCALE_ROWS })\n return rows.map((r) => {\n const row = r as Record<string, unknown>\n return {\n code: String(row.code),\n label: typeof row.label === 'string' && row.label.length > 0 ? row.label : String(row.code),\n enabled: row.enabled !== false,\n direction: row.direction === 'rtl' ? 'rtl' : 'ltr',\n sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : 0,\n }\n })\n}\n\n/**\n * The effective content-locale set — shipped defaults ⊕ DB overrides, sorted by\n * `sortOrder`. Includes **disabled** locales (listable in the Languages editor);\n * callers that want only switchable/valid locales use {@link enabledLocaleCodes}.\n */\nexport async function resolveEffectiveLocales(app: ToolkitApp): Promise<LocaleEntry[]> {\n let rows: LocaleEntry[]\n try {\n rows = await readLocaleRows(app)\n } catch (err) {\n app.logger.warn({ err }, 'locale registry read failed — falling back to content() defaults')\n return [...shippedDefaults()].sort(bySortOrder)\n }\n const merged = mergeConfigOverrides<LocaleEntry, LocaleEntry>({\n defaults: shippedDefaults(),\n overrides: rows,\n idOfDefault: (l) => l.code,\n idOfOverride: (r) => r.code,\n // No `isDisabled`: a disabled locale stays in the merged list so the editor\n // can re-enable it — the switcher + validateLocale filter on `enabled`.\n })\n return (merged as LocaleEntry[]).sort(bySortOrder)\n}\n\n/** The **enabled** codes from an effective set — the switcher + validateLocale whitelist. */\nexport function enabledLocaleCodes(entries: readonly LocaleEntry[]): string[] {\n return entries.filter((e) => e.enabled).map((e) => e.code)\n}\n\n/**\n * The resolved global i18n singletons (`i18n` settings ⊕ `content()` defaults).\n * `defaultLocale` is guaranteed to be an **enabled** locale: the configured value\n * wins if enabled, else the `content()` default if enabled, else the first enabled\n * locale (last-resort, deterministic). Pass `entries` to reuse an already-resolved\n * effective set (avoids a second registry read within one request).\n */\nexport async function resolveI18nConfig(\n app: ToolkitApp,\n entries?: readonly LocaleEntry[],\n): Promise<I18nConfig> {\n const effective = entries ?? (await resolveEffectiveLocales(app))\n const enabled = enabledLocaleCodes(effective)\n const shippedDefault = getContentConfig().defaultLocale\n\n let values: Record<string, unknown> = {}\n try {\n // Dynamic import: `@murumets-ee/settings/runtime` evaluates the settings schema\n // (`defineTable`) at module load; importing it eagerly would drag that graph into\n // `@murumets-ee/content`'s main index (where only `resolveEffectiveLocales` — which\n // needs no settings — is on the hot import path). Loaded lazily here, only when the\n // i18n singletons are actually read. (Same pattern as rent/ticketing/consent.)\n const { createSettingsClient } = await import('@murumets-ee/settings/runtime')\n const client = createSettingsClient(i18nSettings, { app })\n values = (await client.getAll()) ?? {}\n } catch (err) {\n app.logger.warn({ err }, 'i18n settings read failed — falling back to content() defaults')\n }\n\n const configuredDefault =\n typeof values.defaultLocale === 'string' && values.defaultLocale.length > 0\n ? values.defaultLocale\n : shippedDefault\n\n const defaultLocale = enabled.includes(configuredDefault)\n ? configuredDefault\n : enabled.includes(shippedDefault)\n ? shippedDefault\n : (enabled[0] ?? shippedDefault)\n\n const rawStrategy = values.routingStrategy\n const routingStrategy: RoutingStrategy = (ROUTING_STRATEGIES as readonly string[]).includes(\n typeof rawStrategy === 'string' ? rawStrategy : '',\n )\n ? (rawStrategy as RoutingStrategy)\n : 'no-prefix-default'\n\n return { defaultLocale, routingStrategy }\n}\n\n/** What {@link resolveSiteI18n} returns. */\nexport interface SiteI18n {\n /** The site resolved, or `undefined` when none could be named (see below). */\n site: string | undefined\n /** The effective registry narrowed to the site (disabled locales kept). */\n entries: LocaleEntry[]\n /**\n * The site's PRESENTATION defaults: the locale a switcher, a sitemap or an\n * unlocalised request falls back to (an ENABLED locale of the site), and its\n * routing strategy.\n *\n * ⚠️ Not the STORAGE default. Which locale a row's base columns hold is an\n * install-wide fact (`content({ defaultLocale })`, `i18n.defaultLocale`) —\n * shared rows (media) are written from every site, so it cannot vary by\n * site. Read {@link install} for it.\n */\n i18n: I18nConfig\n /** The install-wide i18n singletons — the storage default. */\n install: I18nConfig\n}\n\n/** The site of `options.site`, the request, or the one site of a single-site install. */\nasync function nameSite(app: ToolkitApp, site: string | undefined): Promise<string | undefined> {\n return site ?? getCurrentSite() ?? (await resolveImplicitSite(app).catch(() => undefined))\n}\n\n/**\n * The site's registry row. A failed read PROPAGATES: answering the install-wide\n * set instead would silently lift the site's locale restriction (fail open).\n */\nfunction siteRecord(app: ToolkitApp, site: string): Promise<SiteRecordView | undefined> {\n return readSiteRecord(app, site)\n}\n\nfunction narrowTo(effective: LocaleEntry[], record: SiteRecordView | undefined): LocaleEntry[] {\n const served = record?.locales ?? []\n return served.length === 0 ? effective : effective.filter((e) => served.includes(e.code))\n}\n\n/**\n * The effective registry narrowed to ONE SITE's locales (plan `multisite`\n * phase 03) — the whitelist alone, without the i18n settings read, for the\n * per-request callers (the admin API handler) that need only the set.\n */\nexport async function resolveSiteLocales(\n app: ToolkitApp,\n options: { site?: string; entries?: readonly LocaleEntry[] } = {},\n): Promise<{ site: string | undefined; entries: LocaleEntry[] }> {\n const effective = [...(options.entries ?? (await resolveEffectiveLocales(app)))]\n const site = await nameSite(app, options.site)\n if (site === undefined) return { site, entries: effective }\n return { site, entries: narrowTo(effective, await siteRecord(app, site)) }\n}\n\n/**\n * The locale set and presentation defaults of ONE SITE (plan `multisite` D007,\n * phase 03): the effective registry narrowed to the site record's `locales`\n * (empty = every locale), with the site's own default and routing strategy.\n *\n * - `entries` keeps disabled locales (like {@link resolveEffectiveLocales});\n * the whitelist is `enabledLocaleCodes(entries)`.\n * - `i18n.defaultLocale` is an ENABLED locale of the site. On an app serving\n * several sites the record's `defaultLocale` / `routingStrategy` win; on a\n * single-site install the install's i18n settings stay authoritative (the\n * record only holds a one-time copy of them, and the Languages editor writes\n * the settings), so nothing about a one-site install changes.\n * - The site is `options.site`, else the request's, else the single site of a\n * one-site install. With none of those (a multi-site request that names no\n * site) the install-wide set is returned — what a SHARED entity (media) edits\n * against; a bound entity refuses on its own, without a site.\n */\nexport async function resolveSiteI18n(\n app: ToolkitApp,\n options: { site?: string; entries?: readonly LocaleEntry[] } = {},\n): Promise<SiteI18n> {\n const effective = [...(options.entries ?? (await resolveEffectiveLocales(app)))]\n const install = await resolveI18nConfig(app, effective)\n const site = await nameSite(app, options.site)\n if (site === undefined) return { site, entries: effective, i18n: install, install }\n\n const record = await siteRecord(app, site)\n const narrowed = narrowTo(effective, record)\n const enabled = enabledLocaleCodes(narrowed)\n const multiSite = app.config.sites?.multiple === true\n const wanted = multiSite ? record?.defaultLocale : undefined\n const defaultLocale =\n wanted !== undefined && enabled.includes(wanted)\n ? wanted\n : enabled.includes(install.defaultLocale)\n ? install.defaultLocale\n : (enabled[0] ?? install.defaultLocale)\n const strategy = multiSite ? record?.routingStrategy : undefined\n const routingStrategy: RoutingStrategy = (ROUTING_STRATEGIES as readonly string[]).includes(\n strategy ?? '',\n )\n ? (strategy as RoutingStrategy)\n : install.routingStrategy\n return { site, entries: narrowed, i18n: { defaultLocale, routingStrategy }, install }\n}\n\n/**\n * The install's base language for the `'sites.baseLocale'` contribution\n * (multisite F034) — the default of {@link resolveI18nConfig}, which is the\n * language every row's base columns hold. `undefined` when the app has no\n * language registry, so the site registry's guard then applies no rule: the\n * same answer the Sites API gives.\n */\nexport async function resolveInstallBaseLocale(): Promise<string | undefined> {\n const app = getApp()\n if (!app.entities.has('locale')) return undefined\n return (await resolveI18nConfig(app)).defaultLocale\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{r as e}from"./errors-NRNILhhm.mjs";import{t}from"./content-config-store-8omQF5p5.mjs";import{getContext as n}from"@murumets-ee/core";function r(r){if(r===`_`)return;let i=n()?.availableLocales??t().locales.map(e=>e.code);for(let e of i)if(e===r)return;throw new e(r,[...i])}export{r as t};
2
+ //# sourceMappingURL=locale-utils-B0mFoV47.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-utils-B0mFoV47.mjs","names":[],"sources":["../src/locale-utils.ts"],"sourcesContent":["/**\n * Locale validation helpers.\n *\n * Every ContentClient and LockService method that accepts a `locale`\n * parameter must run it through `validateLocale()` before passing it to\n * the database. Drizzle parameterizes queries so SQL injection isn't a\n * concern, but unvalidated locale strings:\n * - bypass the configured locale set (typo'd `'frr'` writes a row that\n * never resolves on read),\n * - silently truncate at the column's `varchar(10)` boundary (e.g.\n * `'pt-BR-traditional'` → `'pt-BR-trad'`, breaking later equality),\n * - pollute drafts/versions/locale_status tables with rows that no\n * UI ever queries.\n *\n * The `'_'` sentinel is allowed because it's the conventional value for\n * default-locale rows in versions/drafts (where every row needs a\n * non-null `locale` for unique-constraint composition).\n */\n\nimport { getContext } from '@murumets-ee/core'\n// The LEAF definition, not the `./plugin.js` re-export — `plugin.ts` imports\n// this very module (indirectly, through `document-version-store.ts`'s\n// contribution), so importing `getContentConfig` through the barrel would\n// close a cycle. `plugin.ts` re-exports it only for external consumers of\n// `@murumets-ee/content/plugin`; internal modules use the leaf directly.\nimport { getContentConfig } from './content-config-store.js'\nimport { InvalidLocaleError } from './errors.js'\n\n/**\n * The internal sentinel for \"default locale\" used in version/draft storage.\n * `@murumets-ee/entity/admin` exports the same constant (where\n * `updateForLocale` defines the cross-package contract); the `satisfies`\n * pins the two literals together at compile time — drift on either side is\n * a type error — without a runtime import (which would force every unit\n * test that mocks `entity/admin` to spread `importOriginal`).\n */\nexport const DEFAULT_LOCALE_SENTINEL = '_' satisfies typeof import(\n '@murumets-ee/entity/admin'\n)['DEFAULT_LOCALE_SENTINEL']\n\n/**\n * Throw `InvalidLocaleError` if `locale` is neither the `'_'` sentinel nor one of\n * the **effective enabled** content-locale codes for the current request.\n *\n * The allowed set is the request-scoped {@link RequestContext.availableLocales}\n * (plan/config phase 05 §7, SD006) — the read-time-merge of `content()` shipped\n * defaults ⊕ DB `locale` override rows, resolved once per request at context\n * establishment. So a locale added/enabled at runtime validates with **no rebuild**,\n * and a disabled one is rejected — without a globalThis cache (multi-process /\n * multi-tenant correct).\n *\n * **Fallback:** outside a request context (build-time, CLI, unit tests, or the rare\n * concurrent-render ALS-scope escape) `availableLocales` is absent, so the check\n * falls back to the static `content()` codes — same behavior as before SD006, and\n * still deny-by-default (an unknown locale rejects either way).\n */\nexport function validateLocale(locale: string): void {\n if (locale === DEFAULT_LOCALE_SENTINEL) return\n const allowed = getContext()?.availableLocales ?? getContentConfig().locales.map((l) => l.code)\n for (const code of allowed) {\n if (code === locale) return\n }\n throw new InvalidLocaleError(locale, [...allowed])\n}\n"],"mappings":"4IAwDA,SAAgB,EAAe,EAAsB,CACnD,GAAI,IAAA,IAAoC,OACxC,IAAM,EAAU,EAAW,CAAC,EAAE,kBAAoB,EAAiB,CAAC,CAAC,QAAQ,IAAK,GAAM,EAAE,IAAI,EAC9F,IAAK,IAAM,KAAQ,EACjB,GAAI,IAAS,EAAQ,OAEvB,MAAM,IAAI,EAAmB,EAAQ,CAAC,GAAG,CAAO,CAAC,CACnD"}
@@ -0,0 +1,58 @@
1
+ import { c as LockInfo } from "./types-BOk9tg_9.mjs";
2
+ import { ToolkitApp } from "@murumets-ee/core";
3
+ //#region src/lock-service.d.ts
4
+ interface LockServiceConfig {
5
+ app: ToolkitApp;
6
+ }
7
+ declare class LockService {
8
+ private client;
9
+ constructor(config: LockServiceConfig);
10
+ /**
11
+ * Acquire a lock on an entity+locale, atomically.
12
+ *
13
+ * Implemented as a single `INSERT … ON CONFLICT DO UPDATE … WITH SET WHERE`
14
+ * via {@link TableClient.tryClaim} — no `deleteMany → findOne → insert`
15
+ * race. Two concurrent acquires of the same free slot produce exactly
16
+ * one `acquired: true` and one `acquired: false`; the loser sees the
17
+ * winner's row.
18
+ *
19
+ * Replace semantics: take the slot if **any** of these is true:
20
+ * - the slot is empty (INSERT path),
21
+ * - the existing row is past its expiry (`expiresAt < now`),
22
+ * - the existing row is held by the same user (re-entrant renew).
23
+ *
24
+ * Otherwise — fresh lock by another user — return `{ acquired: false,
25
+ * lock }` so the caller can render "X is editing".
26
+ */
27
+ acquireLock(entityType: string, entityId: string, locale: string, user: {
28
+ id: string;
29
+ name?: string;
30
+ }): Promise<{
31
+ acquired: true;
32
+ } | {
33
+ acquired: false;
34
+ lock: LockInfo;
35
+ }>;
36
+ /**
37
+ * Renew an existing lock (heartbeat).
38
+ * Only succeeds if the caller holds the lock and it hasn't expired.
39
+ */
40
+ renewLock(entityType: string, entityId: string, locale: string, userId: string): Promise<boolean>;
41
+ /**
42
+ * Release a lock. Only the lock holder can release their own lock.
43
+ */
44
+ releaseLock(entityType: string, entityId: string, locale: string, userId: string): Promise<boolean>;
45
+ /**
46
+ * Force-release a lock regardless of who holds it.
47
+ * Admin-only operation — must be audit-logged by the caller.
48
+ */
49
+ forceReleaseLock(entityType: string, entityId: string, locale: string): Promise<boolean>;
50
+ /**
51
+ * Get the current lock for an entity+locale.
52
+ * Returns null if no lock or lock has expired.
53
+ */
54
+ getLock(entityType: string, entityId: string, locale: string): Promise<LockInfo | null>;
55
+ }
56
+ //#endregion
57
+ export { LockService, LockServiceConfig };
58
+ //# sourceMappingURL=lock-service.d.mts.map
@@ -0,0 +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"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./locks-5779ugSW.mjs";import"server-only";var t=class{client;constructor(t){this.client=e.makeClient(t.app.db.readWrite)}async acquireLock(e,t,n,r){let i=new Date,a=new Date(i.getTime()+300*1e3),o=await this.client.tryClaim({entityType:e,entityId:t,locale:n,lockedBy:r.id,lockedByName:r.name??null,lockedAt:i,expiresAt:a},{target:[`entityType`,`entityId`,`locale`],replaceWhen:{$or:[{expiresAt:{lt:i}},{lockedBy:r.id}]}});return o.acquired?{acquired:!0}:{acquired:!1,lock:{lockedBy:o.row.lockedBy,lockedByName:o.row.lockedByName??null,lockedAt:o.row.lockedAt,expiresAt:o.row.expiresAt}}}async renewLock(e,t,n,r){let i=new Date,a=new Date(i.getTime()+300*1e3);return await this.client.update({entityType:e,entityId:t,locale:n,lockedBy:r,expiresAt:{gte:i}},{expiresAt:a})!==null}async releaseLock(e,t,n,r){return await this.client.delete({entityType:e,entityId:t,locale:n,lockedBy:r})!==null}async forceReleaseLock(e,t,n){return await this.client.delete({entityType:e,entityId:t,locale:n})!==null}async getLock(e,t,n){let r=new Date,i=await this.client.findOne({entityType:e,entityId:t,locale:n,expiresAt:{gte:r}});return i?{lockedBy:i.lockedBy,lockedByName:i.lockedByName??null,lockedAt:i.lockedAt,expiresAt:i.expiresAt}:null}};export{t as LockService};
2
+ //# sourceMappingURL=lock-service.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock-service.mjs","names":[],"sources":["../src/lock-service.ts"],"sourcesContent":["/**\n * Content lock service — pessimistic locking for entity editing.\n *\n * Prevents concurrent edit conflicts by allowing only one editor at a time\n * per entity+locale combination. Locks expire after 5 minutes and must\n * be renewed via heartbeat.\n *\n * Uses TableClient for all DB operations — zero direct Drizzle queries.\n */\n\nimport 'server-only'\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { contentLocksTable } from './schema/locks.js'\nimport type { LockInfo } from './types.js'\n\n/** Lock duration in minutes */\nconst LOCK_DURATION_MINUTES = 5\n\ntype LockClient = ReturnType<typeof contentLocksTable.makeClient>\n\nexport interface LockServiceConfig {\n app: ToolkitApp\n}\n\nexport class LockService {\n private client: LockClient\n\n constructor(config: LockServiceConfig) {\n this.client = contentLocksTable.makeClient(config.app.db.readWrite)\n }\n\n /**\n * Acquire a lock on an entity+locale, atomically.\n *\n * Implemented as a single `INSERT … ON CONFLICT DO UPDATE … WITH SET WHERE`\n * via {@link TableClient.tryClaim} — no `deleteMany → findOne → insert`\n * race. Two concurrent acquires of the same free slot produce exactly\n * one `acquired: true` and one `acquired: false`; the loser sees the\n * winner's row.\n *\n * Replace semantics: take the slot if **any** of these is true:\n * - the slot is empty (INSERT path),\n * - the existing row is past its expiry (`expiresAt < now`),\n * - the existing row is held by the same user (re-entrant renew).\n *\n * Otherwise — fresh lock by another user — return `{ acquired: false,\n * lock }` so the caller can render \"X is editing\".\n */\n async acquireLock(\n entityType: string,\n entityId: string,\n locale: string,\n user: { id: string; name?: string },\n ): Promise<{ acquired: true } | { acquired: false; lock: LockInfo }> {\n const now = new Date()\n const expiresAt = new Date(now.getTime() + LOCK_DURATION_MINUTES * 60 * 1000)\n\n const result = await this.client.tryClaim(\n {\n entityType,\n entityId,\n locale,\n lockedBy: user.id,\n lockedByName: user.name ?? null,\n lockedAt: now,\n expiresAt,\n },\n {\n target: ['entityType', 'entityId', 'locale'],\n // Replace if expired OR if I already hold this slot. Using $or\n // lets re-entrancy and stale-takeover share one atomic statement.\n replaceWhen: {\n $or: [{ expiresAt: { lt: now } }, { lockedBy: user.id }],\n },\n },\n )\n\n if (result.acquired) return { acquired: true }\n\n return {\n acquired: false,\n lock: {\n lockedBy: result.row.lockedBy,\n lockedByName: result.row.lockedByName ?? null,\n lockedAt: result.row.lockedAt,\n expiresAt: result.row.expiresAt,\n },\n }\n }\n\n /**\n * Renew an existing lock (heartbeat).\n * Only succeeds if the caller holds the lock and it hasn't expired.\n */\n async renewLock(\n entityType: string,\n entityId: string,\n locale: string,\n userId: string,\n ): Promise<boolean> {\n const now = new Date()\n const expiresAt = new Date(now.getTime() + LOCK_DURATION_MINUTES * 60 * 1000)\n\n const updated = await this.client.update(\n { entityType, entityId, locale, lockedBy: userId, expiresAt: { gte: now } },\n { expiresAt },\n )\n\n return updated !== null\n }\n\n /**\n * Release a lock. Only the lock holder can release their own lock.\n */\n async releaseLock(\n entityType: string,\n entityId: string,\n locale: string,\n userId: string,\n ): Promise<boolean> {\n const deleted = await this.client.delete({ entityType, entityId, locale, lockedBy: userId })\n return deleted !== null\n }\n\n /**\n * Force-release a lock regardless of who holds it.\n * Admin-only operation — must be audit-logged by the caller.\n */\n async forceReleaseLock(entityType: string, entityId: string, locale: string): Promise<boolean> {\n const deleted = await this.client.delete({ entityType, entityId, locale })\n return deleted !== null\n }\n\n /**\n * Get the current lock for an entity+locale.\n * Returns null if no lock or lock has expired.\n */\n async getLock(entityType: string, entityId: string, locale: string): Promise<LockInfo | null> {\n const now = new Date()\n\n const row = await this.client.findOne({\n entityType,\n entityId,\n locale,\n expiresAt: { gte: now },\n })\n\n if (!row) return null\n\n return {\n lockedBy: row.lockedBy,\n lockedByName: row.lockedByName ?? null,\n lockedAt: row.lockedAt as Date,\n expiresAt: row.expiresAt as Date,\n }\n }\n}\n"],"mappings":"6DAyBA,IAAa,EAAb,KAAyB,CACvB,OAEA,YAAY,EAA2B,CACrC,KAAK,OAAS,EAAkB,WAAW,EAAO,IAAI,GAAG,SAAS,CACpE,CAmBA,MAAM,YACJ,EACA,EACA,EACA,EACmE,CACnE,IAAM,EAAM,IAAI,KACV,EAAY,IAAI,KAAK,EAAI,QAAQ,EAAI,IAA6B,GAAI,EAEtE,EAAS,MAAM,KAAK,OAAO,SAC/B,CACE,aACA,WACA,SACA,SAAU,EAAK,GACf,aAAc,EAAK,MAAQ,KAC3B,SAAU,EACV,WACF,EACA,CACE,OAAQ,CAAC,aAAc,WAAY,QAAQ,EAG3C,YAAa,CACX,IAAK,CAAC,CAAE,UAAW,CAAE,GAAI,CAAI,CAAE,EAAG,CAAE,SAAU,EAAK,EAAG,CAAC,CACzD,CACF,CACF,EAIA,OAFI,EAAO,SAAiB,CAAE,SAAU,EAAK,EAEtC,CACL,SAAU,GACV,KAAM,CACJ,SAAU,EAAO,IAAI,SACrB,aAAc,EAAO,IAAI,cAAgB,KACzC,SAAU,EAAO,IAAI,SACrB,UAAW,EAAO,IAAI,SACxB,CACF,CACF,CAMA,MAAM,UACJ,EACA,EACA,EACA,EACkB,CAClB,IAAM,EAAM,IAAI,KACV,EAAY,IAAI,KAAK,EAAI,QAAQ,EAAI,IAA6B,GAAI,EAO5E,OAAO,MALe,KAAK,OAAO,OAChC,CAAE,aAAY,WAAU,SAAQ,SAAU,EAAQ,UAAW,CAAE,IAAK,CAAI,CAAE,EAC1E,CAAE,WAAU,CACd,IAEmB,IACrB,CAKA,MAAM,YACJ,EACA,EACA,EACA,EACkB,CAElB,OAAO,MADe,KAAK,OAAO,OAAO,CAAE,aAAY,WAAU,SAAQ,SAAU,CAAO,CAAC,IACxE,IACrB,CAMA,MAAM,iBAAiB,EAAoB,EAAkB,EAAkC,CAE7F,OAAO,MADe,KAAK,OAAO,OAAO,CAAE,aAAY,WAAU,QAAO,CAAC,IACtD,IACrB,CAMA,MAAM,QAAQ,EAAoB,EAAkB,EAA0C,CAC5F,IAAM,EAAM,IAAI,KAEV,EAAM,MAAM,KAAK,OAAO,QAAQ,CACpC,aACA,WACA,SACA,UAAW,CAAE,IAAK,CAAI,CACxB,CAAC,EAID,OAFK,EAEE,CACL,SAAU,EAAI,SACd,aAAc,EAAI,cAAgB,KAClC,SAAU,EAAI,SACd,UAAW,EAAI,SACjB,EAPiB,IAQnB,CACF"}
@@ -0,0 +1,2 @@
1
+ import{column as e,defineTable as t}from"@murumets-ee/db";const n=t({name:`toolkit_content_locks`,columns:{id:e.uuid({primaryKey:!0,defaultRandom:!0}),entityType:e.varchar({length:100,notNull:!0,pgName:`entity_type`}),entityId:e.varchar({length:255,notNull:!0,pgName:`entity_id`}),locale:e.varchar({length:10,notNull:!0,default:`_`}),lockedBy:e.varchar({length:255,notNull:!0,pgName:`locked_by`}),lockedByName:e.varchar({length:255,pgName:`locked_by_name`}),lockedAt:e.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`locked_at`}),expiresAt:e.timestamp({notNull:!0,withTimezone:!0,pgName:`expires_at`})},unique:[{on:[`entityType`,`entityId`,`locale`]}]}),r=n.table;export{r as n,n as t};
2
+ //# sourceMappingURL=locks-5779ugSW.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locks-5779ugSW.mjs","names":[],"sources":["../src/schema/locks.ts"],"sourcesContent":["/**\n * Content lock table schema\n *\n * Single shared table (not per-entity) for pessimistic content locking.\n * Prevents concurrent edit conflicts by allowing only one editor at a time\n * per entity+locale combination.\n *\n * Locks expire after 5 minutes and must be renewed via heartbeat.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\n\nexport const contentLocksTable = defineTable({\n name: 'toolkit_content_locks',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n entityType: column.varchar({ length: 100, notNull: true, pgName: 'entity_type' }),\n entityId: column.varchar({ length: 255, notNull: true, pgName: 'entity_id' }),\n locale: column.varchar({ length: 10, notNull: true, default: '_' }),\n lockedBy: column.varchar({ length: 255, notNull: true, pgName: 'locked_by' }),\n lockedByName: column.varchar({ length: 255, pgName: 'locked_by_name' }),\n lockedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'locked_at',\n }),\n expiresAt: column.timestamp({\n notNull: true,\n withTimezone: true,\n pgName: 'expires_at',\n }),\n },\n unique: [{ on: ['entityType', 'entityId', 'locale'] }],\n})\n\n/** Backwards-compatible export — existing imports of the raw pgTable still work. */\nexport const toolkitContentLocks = contentLocksTable.table\n"],"mappings":"0DAYA,MAAa,EAAoB,EAAY,CAC3C,KAAM,wBACN,QAAS,CACP,GAAI,EAAO,KAAK,CAAE,WAAY,GAAM,cAAe,EAAK,CAAC,EACzD,WAAY,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,OAAQ,aAAc,CAAC,EAChF,SAAU,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,OAAQ,WAAY,CAAC,EAC5E,OAAQ,EAAO,QAAQ,CAAE,OAAQ,GAAI,QAAS,GAAM,QAAS,GAAI,CAAC,EAClE,SAAU,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,OAAQ,WAAY,CAAC,EAC5E,aAAc,EAAO,QAAQ,CAAE,OAAQ,IAAK,OAAQ,gBAAiB,CAAC,EACtE,SAAU,EAAO,UAAU,CACzB,QAAS,GACT,WAAY,GACZ,aAAc,GACd,OAAQ,WACV,CAAC,EACD,UAAW,EAAO,UAAU,CAC1B,QAAS,GACT,aAAc,GACd,OAAQ,YACV,CAAC,CACH,EACA,OAAQ,CAAC,CAAE,GAAI,CAAC,aAAc,WAAY,QAAQ,CAAE,CAAC,CACvD,CAAC,EAGY,EAAsB,EAAkB"}