@orthacms/i18n-server 0.0.0-reserve.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +13 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +15 -0
  6. package/dist/lib/content/controllers/get-entry-locales.controller.d.ts +15 -0
  7. package/dist/lib/content/controllers/get-entry-locales.controller.d.ts.map +1 -0
  8. package/dist/lib/content/controllers/get-entry-locales.controller.js +45 -0
  9. package/dist/lib/content/controllers/locale-summary.controller.d.ts +30 -0
  10. package/dist/lib/content/controllers/locale-summary.controller.d.ts.map +1 -0
  11. package/dist/lib/content/controllers/locale-summary.controller.js +61 -0
  12. package/dist/lib/content/controllers/resolve-type.d.ts +9 -0
  13. package/dist/lib/content/controllers/resolve-type.d.ts.map +1 -0
  14. package/dist/lib/content/controllers/resolve-type.js +20 -0
  15. package/dist/lib/content/dto/locale-summary.dto.d.ts +10 -0
  16. package/dist/lib/content/dto/locale-summary.dto.d.ts.map +1 -0
  17. package/dist/lib/content/dto/locale-summary.dto.js +30 -0
  18. package/dist/lib/content/locale-group-lock.d.ts +34 -0
  19. package/dist/lib/content/locale-group-lock.d.ts.map +1 -0
  20. package/dist/lib/content/locale-group-lock.js +38 -0
  21. package/dist/lib/content/services/entry-locale-extension.service.d.ts +188 -0
  22. package/dist/lib/content/services/entry-locale-extension.service.d.ts.map +1 -0
  23. package/dist/lib/content/services/entry-locale-extension.service.js +662 -0
  24. package/dist/lib/content/services/locale-group.service.d.ts +87 -0
  25. package/dist/lib/content/services/locale-group.service.d.ts.map +1 -0
  26. package/dist/lib/content/services/locale-group.service.js +135 -0
  27. package/dist/lib/copilot/i18n-tool.provider.d.ts +53 -0
  28. package/dist/lib/copilot/i18n-tool.provider.d.ts.map +1 -0
  29. package/dist/lib/copilot/i18n-tool.provider.js +157 -0
  30. package/dist/lib/copilot/translation-proposal.applier.d.ts +87 -0
  31. package/dist/lib/copilot/translation-proposal.applier.d.ts.map +1 -0
  32. package/dist/lib/copilot/translation-proposal.applier.js +242 -0
  33. package/dist/lib/copilot/translation-proposal.provider.d.ts +114 -0
  34. package/dist/lib/copilot/translation-proposal.provider.d.ts.map +1 -0
  35. package/dist/lib/copilot/translation-proposal.provider.js +419 -0
  36. package/dist/lib/domain/errors/index.d.ts +4 -0
  37. package/dist/lib/domain/errors/index.d.ts.map +1 -0
  38. package/dist/lib/domain/errors/index.js +9 -0
  39. package/dist/lib/domain/errors/invalid-locale-set.error.d.ts +10 -0
  40. package/dist/lib/domain/errors/invalid-locale-set.error.d.ts.map +1 -0
  41. package/dist/lib/domain/errors/invalid-locale-set.error.js +16 -0
  42. package/dist/lib/domain/errors/invalid-locale.error.d.ts +10 -0
  43. package/dist/lib/domain/errors/invalid-locale.error.d.ts.map +1 -0
  44. package/dist/lib/domain/errors/invalid-locale.error.js +16 -0
  45. package/dist/lib/domain/errors/unknown-locale.error.d.ts +11 -0
  46. package/dist/lib/domain/errors/unknown-locale.error.d.ts.map +1 -0
  47. package/dist/lib/domain/errors/unknown-locale.error.js +18 -0
  48. package/dist/lib/domain/locale-policy.d.ts +42 -0
  49. package/dist/lib/domain/locale-policy.d.ts.map +1 -0
  50. package/dist/lib/domain/locale-policy.js +61 -0
  51. package/dist/lib/domain/value-objects/locale-set.d.ts +41 -0
  52. package/dist/lib/domain/value-objects/locale-set.d.ts.map +1 -0
  53. package/dist/lib/domain/value-objects/locale-set.js +89 -0
  54. package/dist/lib/domain/value-objects/locale.d.ts +55 -0
  55. package/dist/lib/domain/value-objects/locale.d.ts.map +1 -0
  56. package/dist/lib/domain/value-objects/locale.js +73 -0
  57. package/dist/lib/i18n.constants.d.ts +33 -0
  58. package/dist/lib/i18n.constants.d.ts.map +1 -0
  59. package/dist/lib/i18n.constants.js +89 -0
  60. package/dist/lib/i18n.module.d.ts +14 -0
  61. package/dist/lib/i18n.module.d.ts.map +1 -0
  62. package/dist/lib/i18n.module.js +82 -0
  63. package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts +23 -0
  64. package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts.map +1 -0
  65. package/dist/lib/insights/http/controllers/localization-coverage.controller.js +46 -0
  66. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts +59 -0
  67. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts.map +1 -0
  68. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.js +179 -0
  69. package/dist/lib/insights/types/i18n-insights-view.d.ts +83 -0
  70. package/dist/lib/insights/types/i18n-insights-view.d.ts.map +1 -0
  71. package/dist/lib/insights/types/i18n-insights-view.js +10 -0
  72. package/dist/lib/locales/controllers/list-locales.controller.d.ts +26 -0
  73. package/dist/lib/locales/controllers/list-locales.controller.d.ts.map +1 -0
  74. package/dist/lib/locales/controllers/list-locales.controller.js +48 -0
  75. package/dist/lib/locales/services/locale-registry.service.d.ts +26 -0
  76. package/dist/lib/locales/services/locale-registry.service.d.ts.map +1 -0
  77. package/dist/lib/locales/services/locale-registry.service.js +70 -0
  78. package/dist/lib/locales/services/orphaned-locale.checker.d.ts +60 -0
  79. package/dist/lib/locales/services/orphaned-locale.checker.d.ts.map +1 -0
  80. package/dist/lib/locales/services/orphaned-locale.checker.js +114 -0
  81. package/dist/lib/types/locale.d.ts +68 -0
  82. package/dist/lib/types/locale.d.ts.map +1 -0
  83. package/dist/lib/types/locale.js +8 -0
  84. package/dist/lib/utils/i18n-plugin.d.ts +41 -0
  85. package/dist/lib/utils/i18n-plugin.d.ts.map +1 -0
  86. package/dist/lib/utils/i18n-plugin.js +53 -0
  87. package/package.json +47 -0
@@ -0,0 +1,662 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EntryLocaleExtensionService = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const drizzle_orm_1 = require("drizzle-orm");
7
+ const database_1 = require("@orthacms/database");
8
+ const content_server_1 = require("@orthacms/content-server");
9
+ const utils_server_1 = require("@orthacms/utils-server");
10
+ const content_server_2 = require("@orthacms/content-server");
11
+ const i18n_constants_1 = require("../../i18n.constants");
12
+ const locale_registry_service_1 = require("../../locales/services/locale-registry.service");
13
+ const locale_group_lock_1 = require("../locale-group-lock");
14
+ /** Virtual filter field: the group holds a row in this locale. */
15
+ const HAS_LOCALE = 'hasLocale';
16
+ /** Virtual filter field: the group is missing a row in this locale. */
17
+ const MISSING_LOCALE = 'missingLocale';
18
+ /** Virtual filter field: how many locale rows the group holds. */
19
+ const LOCALE_COUNT = 'localeCount';
20
+ /**
21
+ * Operators each virtual field admits (anything else is a 400).
22
+ *
23
+ * **`hasLocale` takes `eq` / `in` only, deliberately.** `ne` and `nin` were
24
+ * permitted and read as the negation of "has this locale" — they are not. They
25
+ * quantify *inside* the EXISTS, so `hasLocale ne "de"` renders
26
+ * `EXISTS(sibling WHERE locale <> 'de')`: "the group holds some locale other
27
+ * than German", which a fully-translated record satisfies. Asked for "records
28
+ * without a German translation" it returned the German ones. `missingLocale` is
29
+ * the field that expresses absence, so the negations are refused here rather
30
+ * than silently answering a different question — a 400 naming the field is a
31
+ * failure the caller can see and correct, a wrong row set is not.
32
+ */
33
+ const LOCALE_FIELD_OPS = {
34
+ [HAS_LOCALE]: new Set([utils_server_1.FilterOperator.Eq, utils_server_1.FilterOperator.In]),
35
+ [MISSING_LOCALE]: new Set([utils_server_1.FilterOperator.Eq, utils_server_1.FilterOperator.In]),
36
+ [LOCALE_COUNT]: new Set([
37
+ utils_server_1.FilterOperator.Eq,
38
+ utils_server_1.FilterOperator.Ne,
39
+ utils_server_1.FilterOperator.Gt,
40
+ utils_server_1.FilterOperator.Gte,
41
+ utils_server_1.FilterOperator.Lt,
42
+ utils_server_1.FilterOperator.Lte
43
+ ])
44
+ };
45
+ /**
46
+ * The i18n plugin's binding of content-server's `CONTENT_ENTRY_EXTENSION`
47
+ * port — where every locale *behavior* lives, so the content library itself
48
+ * stays locale-agnostic:
49
+ *
50
+ * - **List scoping** — the active-locale filter (strict; the default locale
51
+ * when the request names none), plus the relation picker's
52
+ * `localeFallback=default` union.
53
+ * - **Create stamping** — validates the requested slug and stamps the
54
+ * `locale` envelope column (the group id comes from the column default: a
55
+ * plain create starts its own translation group).
56
+ * - **Shared-field sync** — on update, non-`localized` field values propagate
57
+ * to every sibling row of the translation group, inside the same
58
+ * transaction; published siblings are re-validated so a draft edit can
59
+ * never silently invalidate live content. The rewritten siblings are handed
60
+ * back to the entries pipeline, which appends a **revision** for each — their
61
+ * values changed, so their history has to say so.
62
+ * - **Virtual filters** — `hasLocale` / `missingLocale` / `localeCount`,
63
+ * resolved to EXISTS / count subqueries over the group (ridden by the
64
+ * `(locale_group_id, locale)` unique index).
65
+ *
66
+ * Every method no-ops for non-i18n types, as the port contract requires.
67
+ */
68
+ let EntryLocaleExtensionService = class EntryLocaleExtensionService {
69
+ db;
70
+ locales;
71
+ validation;
72
+ relations;
73
+ constructor(db, locales, validation,
74
+ // Relation storage is content's, not ours: reading a link set and
75
+ // replacing one are the same operations the entries pipeline performs,
76
+ // and a second implementation here is how the join table's ordering
77
+ // and de-duplication rules would drift.
78
+ relations) {
79
+ this.db = db;
80
+ this.locales = locales;
81
+ this.validation = validation;
82
+ this.relations = relations;
83
+ }
84
+ /** @inheritdoc */
85
+ listScope(type, workspaceId, params) {
86
+ if (!type.i18n)
87
+ return undefined;
88
+ const requested = this.locales.resolve(params.locale);
89
+ const table = type.table;
90
+ const strict = (0, drizzle_orm_1.eq)(table['locale'], requested.slug);
91
+ const fallback = this.locales.default();
92
+ if (params.localeFallback !== i18n_constants_1.LOCALE_FALLBACK_DEFAULT ||
93
+ requested.slug === fallback.slug) {
94
+ // Strict mode (the records table): untranslated groups are hidden.
95
+ return strict;
96
+ }
97
+ // Fallback mode (the relation picker): one row per group — the
98
+ // requested locale where it exists, else the default-locale row.
99
+ const sibling = (0, drizzle_orm_1.aliasedTable)(type.table, 'locale_sibling');
100
+ const s = sibling;
101
+ const requestedSibling = this.db
102
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
103
+ .from(sibling)
104
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(s['localeGroupId'], table['localeGroupId']), (0, drizzle_orm_1.eq)(s['workspaceId'], workspaceId), (0, drizzle_orm_1.eq)(s['locale'], requested.slug), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(s['deletedAt'])] : [])));
105
+ return (0, drizzle_orm_1.or)(strict, (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['locale'], fallback.slug), (0, drizzle_orm_1.notExists)(requestedSibling)));
106
+ }
107
+ /**
108
+ * @inheritdoc
109
+ *
110
+ * Stamps the validated `locale` (defaulting when absent). When a
111
+ * `localeGroupId` is given the new row **joins that existing group** (a
112
+ * sibling translation) — verified to name a real group in the workspace
113
+ * first (else 404), so a typo can't spawn a stray one-row group. Absent →
114
+ * the column default (`gen_random_uuid()`) starts a fresh group.
115
+ */
116
+ async createColumns(type, workspaceId, params) {
117
+ if (!type.i18n)
118
+ return {};
119
+ const columns = {
120
+ locale: this.locales.resolve(params.locale).slug
121
+ };
122
+ if (params.localeGroupId !== undefined) {
123
+ await this.assertGroupExists(type, params.localeGroupId, workspaceId);
124
+ columns['localeGroupId'] = params.localeGroupId;
125
+ }
126
+ return columns;
127
+ }
128
+ /**
129
+ * Assert a translation group has ≥1 live row in this workspace, so a
130
+ * sibling attaches to a real group. Runs before the create transaction —
131
+ * the (benign) TOCTOU window is covered by the row staying valid and the
132
+ * `(locale_group_id, locale)` unique index still guarding duplicates.
133
+ */
134
+ async assertGroupExists(type, localeGroupId, workspaceId) {
135
+ const table = type.table;
136
+ const [row] = await this.db
137
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
138
+ .from(type.table)
139
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['localeGroupId'], localeGroupId), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(table['deletedAt'])] : [])))
140
+ .limit(1);
141
+ if (!row) {
142
+ throw new common_1.NotFoundException(`No translation group "${localeGroupId}" on "${type.name}".`);
143
+ }
144
+ }
145
+ /**
146
+ * @inheritdoc
147
+ *
148
+ * Takes the translation group's advisory lock, ahead of every row lock the
149
+ * write will acquire. See {@link lockLocaleGroup} for why ordering the
150
+ * sibling `FOR UPDATE` cannot replace this: the edited row is already
151
+ * locked by the pipeline's own `UPDATE` before any i18n code runs, so two
152
+ * saves in one group invert their lock order no matter how the sync sorts
153
+ * its siblings.
154
+ *
155
+ * No group id means a create starting a fresh group — nothing to contend
156
+ * on, so nothing to lock.
157
+ */
158
+ async beforeWrite(tx, type, params) {
159
+ if (!type.i18n || !params.localeGroupId)
160
+ return;
161
+ await (0, locale_group_lock_1.lockLocaleGroup)(tx, params.localeGroupId);
162
+ }
163
+ /** @inheritdoc */
164
+ async afterUpdate(tx, type, row, values, workspaceId, context) {
165
+ if (!type.i18n)
166
+ return [];
167
+ // A newly-created sibling arrives carrying the source's shared *values*
168
+ // (the client copies them) but none of its **relations** — those never
169
+ // travel in a create body. Propagating outward from it would therefore
170
+ // push emptiness onto rows that were already right, wiping the group's
171
+ // links. So a create pulls its relations in from the group instead, and
172
+ // only its columns propagate outward (which the client has already made
173
+ // a no-op in the common case, and which is what makes a deliberately
174
+ // different shared value on a create win — long-standing behavior).
175
+ if (context.created) {
176
+ await this.inheritRelationsFromGroup(tx, type, row, workspaceId);
177
+ }
178
+ return this.propagateToSiblings(tx, type, row, values, workspaceId, {
179
+ relations: !context.created
180
+ });
181
+ }
182
+ /** @inheritdoc */
183
+ async describeFanout(type, entryId, values, workspaceId) {
184
+ if (!type.i18n)
185
+ return undefined;
186
+ // Cheapest question first, and the one that decides the rest: a save
187
+ // touching only per-locale fields is exactly what a translation is, and
188
+ // it must not read the database — the copilot calls this on every
189
+ // proposed edit, including the many that concern one language only.
190
+ const fields = Object.keys(values).filter((name) => this.travelsToSiblings(type, name));
191
+ if (!fields.length)
192
+ return undefined;
193
+ const table = type.table;
194
+ const sibling = (0, drizzle_orm_1.aliasedTable)(type.table, 'fanout_sibling');
195
+ const siblingColumns = sibling;
196
+ // One self-join rather than "read the group id, then read the group":
197
+ // the bulk tool describes up to BULK_MAX_SAVE_ITEMS items in a turn, so
198
+ // halving the round trips per item is worth the denser query.
199
+ //
200
+ // No lock and no `deletedAt` filter, both on purpose. The lock is
201
+ // covered in the port's JSDoc; the trashed rows are here because
202
+ // `propagateToSiblings` rewrites them too (so a restore comes back
203
+ // consistent with its group), and a description that quietly omitted
204
+ // them would understate the change it exists to disclose.
205
+ const rows = (await this.db
206
+ .select({ locale: (0, drizzle_orm_1.sql) `${siblingColumns['locale']}` })
207
+ .from(type.table)
208
+ .innerJoin(sibling, (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(siblingColumns['localeGroupId'], table['localeGroupId']), (0, drizzle_orm_1.ne)(siblingColumns['id'], table['id']), (0, drizzle_orm_1.eq)(siblingColumns['workspaceId'], workspaceId)))
209
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['id'], entryId), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId))));
210
+ const locales = [...new Set(rows.map((row) => row.locale))].sort();
211
+ // An entry alone in its group is an ordinary single-row edit. Saying so
212
+ // would put a caveat on the majority of edits and teach the reader to
213
+ // skim past it on the one that matters.
214
+ if (!locales.length)
215
+ return undefined;
216
+ return { fields, locales };
217
+ }
218
+ /**
219
+ * Whether this field's value reaches the row's siblings when it is saved —
220
+ * the predicate behind {@link describeFanout}, stated once against the same
221
+ * three rules {@link propagateToSiblings} applies.
222
+ *
223
+ * It is broader than {@link sharedColumns}, deliberately: that method
224
+ * answers "which columns copy across verbatim", because shared columns,
225
+ * mirrored FKs and join-backed link sets are *applied* by three different
226
+ * mechanisms. To a person being told what their change will do, the three
227
+ * are one fact — the value they set here lands over there.
228
+ *
229
+ * A **mirrored** relation counts even though each sibling ends up holding a
230
+ * different id: the sibling's link is being repointed at the record this
231
+ * save chose, which is a change to that row whatever id expresses it.
232
+ */
233
+ travelsToSiblings(type, name) {
234
+ const spec = type.fields[name];
235
+ if (!spec)
236
+ return false;
237
+ // Checked ahead of the relation rules for the same reason
238
+ // `sharedColumns` checks it first: `localized` is the author's explicit
239
+ // "this varies per language", and a field carrying it never propagates
240
+ // however its relation options happen to read.
241
+ if (spec.localized)
242
+ return false;
243
+ if (spec.type === content_server_2.CONTENT_FIELD_TYPE.Relation) {
244
+ return (0, content_server_1.relationLocaleSync)(type, spec) !== content_server_1.RELATION_LOCALE_SYNC.None;
245
+ }
246
+ return true;
247
+ }
248
+ /**
249
+ * Push this row's state onto every sibling in its translation group, and
250
+ * return the rows actually rewritten (the pipeline appends a revision for
251
+ * each — their content moved in this transaction, and without a version
252
+ * their history would skip the change and a later restore would silently
253
+ * undo it).
254
+ *
255
+ * Three kinds of state travel, each computed once and applied per sibling:
256
+ * shared **columns** (identical everywhere), **mirrored** single-relation
257
+ * FKs (resolved into each sibling's own locale), and the **link sets** of
258
+ * join-backed relations. A sibling whose every one of those already matches
259
+ * is left completely untouched — that guard is what stops one save
260
+ * re-versioning the whole group.
261
+ */
262
+ async propagateToSiblings(tx, type, row, values, workspaceId, options) {
263
+ const table = type.table;
264
+ // Only the columns this save actually carries. A field the caller
265
+ // omitted is `undefined`, which `.set()` skips — so it must be left out
266
+ // of the change predicate too, or it would compare against a missing
267
+ // bind parameter.
268
+ const shared = Object.entries(this.sharedColumns(type, values) ?? {})
269
+ .filter(([, value]) => value !== undefined)
270
+ .map(([column, value]) => [column, value]);
271
+ // Decide whether anything CAN travel before reading — and, more to the
272
+ // point, before locking — anything. Both inputs are free: the columns
273
+ // come from the save's own values bag, and whether a relation
274
+ // propagates is a property of the schema.
275
+ //
276
+ // Skipping the query is not just an optimization. The read below takes
277
+ // `FOR UPDATE` on every sibling of the group, so running it
278
+ // unconditionally would make each save of a localized type lock rows it
279
+ // has no intention of writing — on a type with no shared field and no
280
+ // syncing relation, permanently and for nothing. The pre-relation code
281
+ // had this property (it returned before touching the database when
282
+ // there were no shared columns); restoring it keeps the lock footprint
283
+ // proportional to what the save actually propagates.
284
+ const canSyncRelations = options.relations &&
285
+ Object.values(type.fields).some((spec) => (0, content_server_1.relationLocaleSync)(type, spec) !== content_server_1.RELATION_LOCALE_SYNC.None);
286
+ if (!shared.length && !canSyncRelations)
287
+ return [];
288
+ // Every sibling, locked. We need each row's own `locale` (to resolve a
289
+ // mirrored link into it), and its **pre-write** publish status — the
290
+ // re-validation below has to read the status from before the demotion,
291
+ // since the UPDATE sets every touched row to `draft`. Soft-deleted
292
+ // siblings are included, so a later restore comes back consistent with
293
+ // the group. The lock also serializes two concurrent saves in different
294
+ // locales of the same record, which would otherwise interleave.
295
+ //
296
+ // Ordered by id so the rows are locked in one canonical sequence. That
297
+ // is a determinism guard, **not** the deadlock fix: the entry the
298
+ // caller edited was already locked by the pipeline's own `UPDATE`
299
+ // before this hook ran, so ordering the siblings alone still leaves two
300
+ // savers each holding the row the other wants. `beforeWrite`'s group
301
+ // advisory lock is what makes the inversion unreachable — see
302
+ // {@link lockLocaleGroup}.
303
+ const siblings = (await tx
304
+ .select()
305
+ .from(type.table)
306
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['localeGroupId'], row['localeGroupId']), (0, drizzle_orm_1.ne)(table['id'], row['id']), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId)))
307
+ .orderBy((0, drizzle_orm_1.sql) `${table['id']}`)
308
+ .for('update'));
309
+ if (!siblings.length)
310
+ return [];
311
+ const locales = [
312
+ ...new Set(siblings.map((sibling) => sibling['locale']))
313
+ ];
314
+ const mirrored = options.relations
315
+ ? await this.mirroredColumnsByLocale(tx, type, values, locales, workspaceId)
316
+ : new Map();
317
+ const links = options.relations
318
+ ? await this.linkSetsByLocale(tx, type, row['id'], locales, workspaceId)
319
+ : new Map();
320
+ const rewritten = [];
321
+ for (const sibling of siblings) {
322
+ const siblingId = sibling['id'];
323
+ const locale = sibling['locale'];
324
+ // Links first, so a links-only change still reaches the row write
325
+ // below — that is what stamps `updatedAt`, demotes a published
326
+ // sibling, and earns the row its revision.
327
+ let linksMoved = false;
328
+ for (const [field, byLocale] of links) {
329
+ const moved = await this.relations.replaceLinks(tx, type, siblingId, field, byLocale.get(locale) ?? []);
330
+ linksMoved ||= moved;
331
+ }
332
+ const columns = [
333
+ ...shared,
334
+ ...Object.entries(mirrored.get(locale) ?? {})
335
+ ];
336
+ // Nothing to write and nothing moved — leave the row (and its
337
+ // history) completely alone. Guarding here matters: an empty
338
+ // `differs` collapses to `undefined`, which would widen the UPDATE
339
+ // to every sibling unconditionally.
340
+ if (!columns.length && !linksMoved)
341
+ continue;
342
+ // Only rewrite a sibling whose columns actually differ. The save's
343
+ // values bag carries every field the caller sent, so an
344
+ // unconditional UPDATE re-versioned every sibling on every save,
345
+ // even one that only changed a localized field. `IS DISTINCT FROM`
346
+ // rather than `<>` so a NULL on either side compares correctly.
347
+ //
348
+ // Each value is bound with **its column's own encoder**
349
+ // (`sql.param`), the same mapping `.set()` applies. Interpolating it
350
+ // bare made an array-valued field (a `jsonb` multiselect, json, or a
351
+ // multiple media field) expand into a parameter *list* —
352
+ // `IS DISTINCT FROM ($1, $2, $3)` — which Postgres reads as a record
353
+ // and rejects with `operator does not exist: jsonb = record`,
354
+ // failing every save of an i18n type that carried one.
355
+ const differs = (0, drizzle_orm_1.or)(...columns.map(([column, value]) => (0, drizzle_orm_1.sql) `${table[column]} IS DISTINCT FROM ${drizzle_orm_1.sql.param(value, table[column])}`));
356
+ // When the links moved the row has already changed, so it is
357
+ // written regardless of whether any column did.
358
+ const where = linksMoved
359
+ ? (0, drizzle_orm_1.eq)(table['id'], siblingId)
360
+ : (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['id'], siblingId), differs);
361
+ // A **published** sibling moves back to `draft`, exactly as the
362
+ // entry the user edited does. Its content just changed, so its
363
+ // published *version* is no longer what the row holds — leaving it
364
+ // `published` made the same edit live in the untouched locales
365
+ // while still pending in the edited one, and left its
366
+ // freshly-appended draft version describing a row that claimed to
367
+ // be live. `published_at` is kept, so the sibling reads as
368
+ // **Modified** (live content, unpublished changes) rather than as a
369
+ // never-published draft. Publishing any locale is still per-row;
370
+ // this only stops one going live on another's save.
371
+ const [updated] = (await tx
372
+ .update(type.table)
373
+ .set({
374
+ ...Object.fromEntries(columns),
375
+ ...(type.publishable ? { status: content_server_2.ENTRY_STATUS.Draft } : {}),
376
+ updatedAt: new Date()
377
+ })
378
+ .where(where)
379
+ .returning());
380
+ if (!updated)
381
+ continue; // nothing differed after all
382
+ // A sibling that **was** published must stay valid once the synced
383
+ // state lands — re-validate and abort the whole save otherwise.
384
+ // Drafts may be temporarily invalid (the same rule as saving a
385
+ // draft directly). This reads the pre-write status captured in the
386
+ // locked SELECT, not the row's current one: the UPDATE has just
387
+ // demoted it to `draft`, so testing the returned status would
388
+ // silently skip every sibling.
389
+ if (type.publishable &&
390
+ sibling['status'] === content_server_2.ENTRY_STATUS.Published) {
391
+ const result = this.validation.validate(type, (0, content_server_1.toRecord)(type, updated).values);
392
+ if (!result.valid) {
393
+ throw new common_1.UnprocessableEntityException({
394
+ message: `Shared fields would invalidate the published ` +
395
+ `"${locale}" translation`,
396
+ issues: result.issues
397
+ });
398
+ }
399
+ }
400
+ rewritten.push(updated);
401
+ }
402
+ return rewritten;
403
+ }
404
+ /**
405
+ * Fill a **newly created** sibling's propagating relations from the group
406
+ * it just joined — the half of the sync that runs inward.
407
+ *
408
+ * A translation is created through the ordinary create route with a
409
+ * `localeGroupId`, and a create body carries no relation links at all: they
410
+ * are join-backed (never in the values bag) or per-locale (deliberately
411
+ * dropped by the client, since the source's id names another language's
412
+ * row). So without this a translation is born with none of the record's
413
+ * links, which is exactly the manual per-locale re-linking this sync
414
+ * exists to remove.
415
+ *
416
+ * The donor is one existing sibling — any of them agree on a shared link,
417
+ * and a mirrored one resolves through the same translation group whichever
418
+ * is asked — picked deterministically (the default locale where the group
419
+ * has it) so two creates never inherit from different rows.
420
+ *
421
+ * Columns it derives are also written back onto `row` **in place**: the
422
+ * caller snapshots that object as the new entry's first revision *after*
423
+ * this call, so a database-only write would leave version 1 describing
424
+ * something the row never held.
425
+ */
426
+ async inheritRelationsFromGroup(tx, type, row, workspaceId) {
427
+ const table = type.table;
428
+ const locale = row['locale'];
429
+ const donors = (await tx
430
+ .select()
431
+ .from(type.table)
432
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['localeGroupId'], row['localeGroupId']), (0, drizzle_orm_1.ne)(table['id'], row['id']), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(table['deletedAt'])] : []))));
433
+ if (!donors.length)
434
+ return; // a fresh group: nothing to inherit
435
+ const fallback = this.locales.default().slug;
436
+ const donor = donors.find((candidate) => candidate['locale'] === fallback) ??
437
+ [...donors].sort((a, b) => a['locale'].localeCompare(b['locale']))[0];
438
+ // Reuse the outward machinery with the roles swapped: the donor is the
439
+ // source, and this new row is the only "sibling" being written to.
440
+ const mirrored = await this.mirroredColumnsByLocale(tx, type, (0, content_server_1.toRecord)(type, donor).values, [locale], workspaceId);
441
+ const links = await this.linkSetsByLocale(tx, type, donor['id'], [locale], workspaceId);
442
+ for (const [field, byLocale] of links) {
443
+ await this.relations.replaceLinks(tx, type, row['id'], field, byLocale.get(locale) ?? []);
444
+ }
445
+ const columns = Object.entries(mirrored.get(locale) ?? {});
446
+ if (!columns.length)
447
+ return;
448
+ const [updated] = (await tx
449
+ .update(type.table)
450
+ .set(Object.fromEntries(columns))
451
+ .where((0, drizzle_orm_1.eq)(table['id'], row['id']))
452
+ .returning());
453
+ // Keep the caller's row object in step with what was just stored (see
454
+ // the note above about the first revision).
455
+ if (updated)
456
+ Object.assign(row, updated);
457
+ }
458
+ /**
459
+ * Per locale, the **mirrored** single-relation FK columns a sibling in that
460
+ * locale should hold: the source's linked record, resolved to that record's
461
+ * own translation in the sibling's language.
462
+ *
463
+ * An unresolvable link becomes `null` rather than being skipped. Leaving
464
+ * the sibling's existing value would keep it pointing at the *previous*
465
+ * record's translation — silently wrong data, which is worse than an empty
466
+ * field that the editor can see and the publish gate can block on.
467
+ */
468
+ async mirroredColumnsByLocale(tx, type, values, locales, workspaceId) {
469
+ const out = new Map();
470
+ for (const locale of locales)
471
+ out.set(locale, {});
472
+ for (const [name, spec] of Object.entries(type.fields)) {
473
+ if ((0, content_server_1.relationLocaleSync)(type, spec) !==
474
+ content_server_1.RELATION_LOCALE_SYNC.Mirrored ||
475
+ (0, content_server_1.isJoinBackedRelation)(spec)) {
476
+ continue;
477
+ }
478
+ // A field this save didn't carry says nothing about what the
479
+ // siblings should hold (a merge-patch names only what changed).
480
+ if (!(name in values))
481
+ continue;
482
+ const sourceId = values[name];
483
+ if (typeof sourceId !== 'string' || !sourceId) {
484
+ for (const locale of locales)
485
+ out.get(locale)[name] = null;
486
+ continue;
487
+ }
488
+ const equivalents = await this.relations.equivalentIdsByLocale(tx, spec.relation?.to(), [sourceId], locales, workspaceId);
489
+ for (const locale of locales) {
490
+ out.get(locale)[name] =
491
+ equivalents.get(locale)?.get(sourceId) ?? null;
492
+ }
493
+ }
494
+ return out;
495
+ }
496
+ /**
497
+ * Per join-backed relation field, per locale, the ordered link ids a
498
+ * sibling in that locale should hold.
499
+ *
500
+ * A **shared** relation hands every sibling the source's ids verbatim — the
501
+ * target has no locales, so there is one right row for the whole group. A
502
+ * **mirrored** one maps each id through its translation group into the
503
+ * sibling's language, preserving the source's order and dropping links
504
+ * whose target has no translation there (a content gap on the target, not a
505
+ * reason to fail this save).
506
+ */
507
+ async linkSetsByLocale(tx, type, sourceId, locales, workspaceId) {
508
+ const out = new Map();
509
+ for (const [name, spec] of Object.entries(type.fields)) {
510
+ const mode = (0, content_server_1.relationLocaleSync)(type, spec);
511
+ if (mode === content_server_1.RELATION_LOCALE_SYNC.None)
512
+ continue;
513
+ if (!(0, content_server_1.isJoinBackedRelation)(spec))
514
+ continue;
515
+ const ids = await this.relations.linkIdsOf(tx, type, sourceId, name);
516
+ const byLocale = new Map();
517
+ if (mode === content_server_1.RELATION_LOCALE_SYNC.Shared) {
518
+ for (const locale of locales)
519
+ byLocale.set(locale, ids);
520
+ }
521
+ else {
522
+ const equivalents = await this.relations.equivalentIdsByLocale(tx, spec.relation?.to(), ids, locales, workspaceId);
523
+ for (const locale of locales) {
524
+ const map = equivalents.get(locale);
525
+ byLocale.set(locale, ids
526
+ .map((id) => map?.get(id))
527
+ .filter((id) => !!id));
528
+ }
529
+ }
530
+ out.set(name, byLocale);
531
+ }
532
+ return out;
533
+ }
534
+ /** @inheritdoc */
535
+ filterExtension(type) {
536
+ if (!type.i18n)
537
+ return undefined;
538
+ const slugs = this.locales.all().map((locale) => locale.slug);
539
+ const fields = {
540
+ [HAS_LOCALE]: { type: utils_server_1.ScalarFieldType.Enum, enumValues: slugs },
541
+ [MISSING_LOCALE]: { type: utils_server_1.ScalarFieldType.Enum, enumValues: slugs },
542
+ [LOCALE_COUNT]: { type: utils_server_1.ScalarFieldType.Number }
543
+ };
544
+ return {
545
+ fields,
546
+ resolve: (rule, context) => this.resolveLocaleRule(rule, context)
547
+ };
548
+ }
549
+ /**
550
+ * Translate one virtual locale rule into SQL. All three fields quantify
551
+ * over the row's translation group via a self-EXISTS/count subquery,
552
+ * workspace-scoped, (on paranoid types) live-rows-only, and **restricted to
553
+ * the configured locale set** — the same restriction the coverage query
554
+ * applies (`configuredScope`).
555
+ *
556
+ * That last one is what keeps the records table and the Translation
557
+ * coverage card telling the same story. A row in a slug the host has since
558
+ * dropped is not coverage of anything: coverage filters it out, so leaving
559
+ * it in here made `localeCount` count higher than the number of configured
560
+ * locales, and a record the card reported as fully localized answered
561
+ * neither `localeCount eq 3` (the configured total) nor any `hasLocale`
562
+ * question consistent with it. Two views of one record, disagreeing.
563
+ *
564
+ * `missingLocale in [a, b]` is a `notExists` over the union, so it means
565
+ * "missing **all** of these", not "missing any" — the union has no matching
566
+ * sibling only when every named locale is absent. It is the useful reading
567
+ * for the records table ("show me what has none of my target languages");
568
+ * "missing any" is expressed as an `or` of `missingLocale eq` rules.
569
+ */
570
+ async resolveLocaleRule(rule, context) {
571
+ const field = rule.path[0];
572
+ const allowedOps = LOCALE_FIELD_OPS[field];
573
+ if (!allowedOps?.has(rule.op)) {
574
+ throw new common_1.BadRequestException(`Operator "${rule.op}" is not supported on "${field}".`);
575
+ }
576
+ const { type, workspaceId } = context;
577
+ const table = type.table;
578
+ const sibling = (0, drizzle_orm_1.aliasedTable)(type.table, 'locale_sibling');
579
+ const s = sibling;
580
+ const groupScope = (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(s['localeGroupId'], table['localeGroupId']), (0, drizzle_orm_1.eq)(s['workspaceId'], workspaceId),
581
+ // Only rows in a **configured** locale count as a translation —
582
+ // mirrors the coverage query's `configuredScope`, so the two
583
+ // cannot disagree about the same record.
584
+ (0, drizzle_orm_1.inArray)(s['locale'], this.locales.all().map((locale) => locale.slug)), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(s['deletedAt'])] : []));
585
+ if (field === LOCALE_COUNT) {
586
+ const countSubquery = this.db
587
+ .select({ n: (0, drizzle_orm_1.sql) `count(*)` })
588
+ .from(sibling)
589
+ .where(groupScope);
590
+ const value = rule.value;
591
+ switch (rule.op) {
592
+ case utils_server_1.FilterOperator.Eq:
593
+ return (0, drizzle_orm_1.sql) `(${countSubquery}) = ${value}`;
594
+ case utils_server_1.FilterOperator.Ne:
595
+ return (0, drizzle_orm_1.sql) `(${countSubquery}) <> ${value}`;
596
+ case utils_server_1.FilterOperator.Gt:
597
+ return (0, drizzle_orm_1.sql) `(${countSubquery}) > ${value}`;
598
+ case utils_server_1.FilterOperator.Gte:
599
+ return (0, drizzle_orm_1.sql) `(${countSubquery}) >= ${value}`;
600
+ case utils_server_1.FilterOperator.Lt:
601
+ return (0, drizzle_orm_1.sql) `(${countSubquery}) < ${value}`;
602
+ default:
603
+ return (0, drizzle_orm_1.sql) `(${countSubquery}) <= ${value}`;
604
+ }
605
+ }
606
+ // hasLocale / missingLocale: EXISTS (or its negation) of a sibling row
607
+ // in the named locale(s). Only `eq` and `in` reach here — the negating
608
+ // operators are refused above, because negating *inside* the EXISTS is
609
+ // not the negation of the EXISTS (see LOCALE_FIELD_OPS).
610
+ const localePredicate = rule.op === utils_server_1.FilterOperator.In
611
+ ? (0, drizzle_orm_1.inArray)(s['locale'], rule.value)
612
+ : (0, drizzle_orm_1.eq)(s['locale'], rule.value);
613
+ const siblingInLocale = this.db
614
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
615
+ .from(sibling)
616
+ .where((0, drizzle_orm_1.and)(groupScope, localePredicate));
617
+ return field === HAS_LOCALE
618
+ ? (0, drizzle_orm_1.exists)(siblingInLocale)
619
+ : (0, drizzle_orm_1.notExists)(siblingInLocale);
620
+ }
621
+ /**
622
+ * The column bag of the fields whose value is **identical in every locale**,
623
+ * drawn from a save's coerced values — the part of the sync that is the
624
+ * same for every sibling.
625
+ *
626
+ * Scalars and media qualify unless marked `localized`. A relation qualifies
627
+ * only when it is column-backed *and* {@link RELATION_LOCALE_SYNC.Shared}:
628
+ * a join-backed relation has no column to copy (its links go through
629
+ * {@link linkSetsByLocale}), and a mirrored one stores a different id in
630
+ * every locale by design (see {@link mirroredColumnsByLocale}), so copying
631
+ * one verbatim would be exactly the cross-locale link the writer rejects.
632
+ *
633
+ * `null` when the type has no such field at all.
634
+ */
635
+ sharedColumns(type, values) {
636
+ const sharedFields = Object.entries(type.fields).filter(([, spec]) => {
637
+ if (spec.localized)
638
+ return false;
639
+ if (spec.type !== content_server_2.CONTENT_FIELD_TYPE.Relation)
640
+ return true;
641
+ if (spec.relation?.many || spec.relation?.inverse)
642
+ return false;
643
+ return ((0, content_server_1.relationLocaleSync)(type, spec) === content_server_1.RELATION_LOCALE_SYNC.Shared);
644
+ });
645
+ if (!sharedFields.length)
646
+ return null;
647
+ const allColumns = (0, content_server_1.toColumns)(type, values);
648
+ const shared = {};
649
+ for (const [name] of sharedFields) {
650
+ shared[name] = allColumns[name];
651
+ }
652
+ return shared;
653
+ }
654
+ };
655
+ exports.EntryLocaleExtensionService = EntryLocaleExtensionService;
656
+ exports.EntryLocaleExtensionService = EntryLocaleExtensionService = tslib_1.__decorate([
657
+ (0, common_1.Injectable)(),
658
+ tslib_1.__param(0, (0, database_1.InjectDatabase)()),
659
+ tslib_1.__metadata("design:paramtypes", [Object, locale_registry_service_1.LocaleRegistryService,
660
+ content_server_1.EntryValidationService,
661
+ content_server_1.RelationLinkService])
662
+ ], EntryLocaleExtensionService);