@_linked/translation 0.2.2

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 (153) hide show
  1. package/README.md +102 -0
  2. package/lib/cjs/data/translation.json +8 -0
  3. package/lib/esm/archive.d.ts +133 -0
  4. package/lib/esm/archive.js +567 -0
  5. package/lib/esm/archive.js.map +1 -0
  6. package/lib/esm/authorization.d.ts +12 -0
  7. package/lib/esm/authorization.js +10 -0
  8. package/lib/esm/authorization.js.map +1 -0
  9. package/lib/esm/backend.d.ts +1 -0
  10. package/lib/esm/backend.js +3 -0
  11. package/lib/esm/backend.js.map +1 -0
  12. package/lib/esm/cdn-loader.d.ts +40 -0
  13. package/lib/esm/cdn-loader.js +200 -0
  14. package/lib/esm/cdn-loader.js.map +1 -0
  15. package/lib/esm/checks.d.ts +56 -0
  16. package/lib/esm/checks.js +288 -0
  17. package/lib/esm/checks.js.map +1 -0
  18. package/lib/esm/compile.d.ts +64 -0
  19. package/lib/esm/compile.js +99 -0
  20. package/lib/esm/compile.js.map +1 -0
  21. package/lib/esm/core/messages.d.ts +25 -0
  22. package/lib/esm/core/messages.js +76 -0
  23. package/lib/esm/core/messages.js.map +1 -0
  24. package/lib/esm/data/translation.json +8 -0
  25. package/lib/esm/discovery.d.ts +97 -0
  26. package/lib/esm/discovery.js +365 -0
  27. package/lib/esm/discovery.js.map +1 -0
  28. package/lib/esm/exchange.d.ts +100 -0
  29. package/lib/esm/exchange.js +223 -0
  30. package/lib/esm/exchange.js.map +1 -0
  31. package/lib/esm/export.d.ts +28 -0
  32. package/lib/esm/export.js +92 -0
  33. package/lib/esm/export.js.map +1 -0
  34. package/lib/esm/formats/csv.d.ts +8 -0
  35. package/lib/esm/formats/csv.js +397 -0
  36. package/lib/esm/formats/csv.js.map +1 -0
  37. package/lib/esm/formats/json.d.ts +30 -0
  38. package/lib/esm/formats/json.js +459 -0
  39. package/lib/esm/formats/json.js.map +1 -0
  40. package/lib/esm/formats/xliff.d.ts +12 -0
  41. package/lib/esm/formats/xliff.js +577 -0
  42. package/lib/esm/formats/xliff.js.map +1 -0
  43. package/lib/esm/import.d.ts +197 -0
  44. package/lib/esm/import.js +391 -0
  45. package/lib/esm/import.js.map +1 -0
  46. package/lib/esm/index.d.ts +42 -0
  47. package/lib/esm/index.js +34 -0
  48. package/lib/esm/index.js.map +1 -0
  49. package/lib/esm/key-sync-node.d.ts +29 -0
  50. package/lib/esm/key-sync-node.js +174 -0
  51. package/lib/esm/key-sync-node.js.map +1 -0
  52. package/lib/esm/key-sync.d.ts +46 -0
  53. package/lib/esm/key-sync.js +114 -0
  54. package/lib/esm/key-sync.js.map +1 -0
  55. package/lib/esm/key-version.d.ts +50 -0
  56. package/lib/esm/key-version.js +134 -0
  57. package/lib/esm/key-version.js.map +1 -0
  58. package/lib/esm/languages.d.ts +18 -0
  59. package/lib/esm/languages.js +74 -0
  60. package/lib/esm/languages.js.map +1 -0
  61. package/lib/esm/linked-discovery.d.ts +44 -0
  62. package/lib/esm/linked-discovery.js +267 -0
  63. package/lib/esm/linked-discovery.js.map +1 -0
  64. package/lib/esm/message-format.d.ts +41 -0
  65. package/lib/esm/message-format.js +133 -0
  66. package/lib/esm/message-format.js.map +1 -0
  67. package/lib/esm/mt.d.ts +111 -0
  68. package/lib/esm/mt.js +142 -0
  69. package/lib/esm/mt.js.map +1 -0
  70. package/lib/esm/ontologies/translation.d.ts +203 -0
  71. package/lib/esm/ontologies/translation.js +219 -0
  72. package/lib/esm/ontologies/translation.js.map +1 -0
  73. package/lib/esm/package.d.ts +4 -0
  74. package/lib/esm/package.js +11 -0
  75. package/lib/esm/package.js.map +1 -0
  76. package/lib/esm/react.d.ts +55 -0
  77. package/lib/esm/react.js +127 -0
  78. package/lib/esm/react.js.map +1 -0
  79. package/lib/esm/records.d.ts +151 -0
  80. package/lib/esm/records.js +2 -0
  81. package/lib/esm/records.js.map +1 -0
  82. package/lib/esm/release.d.ts +198 -0
  83. package/lib/esm/release.js +435 -0
  84. package/lib/esm/release.js.map +1 -0
  85. package/lib/esm/shape-catalog.d.ts +79 -0
  86. package/lib/esm/shape-catalog.js +118 -0
  87. package/lib/esm/shape-catalog.js.map +1 -0
  88. package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
  89. package/lib/esm/shapes/GlossaryTerm.js +94 -0
  90. package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
  91. package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
  92. package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
  93. package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
  94. package/lib/esm/shapes/TranslationKey.d.ts +92 -0
  95. package/lib/esm/shapes/TranslationKey.js +194 -0
  96. package/lib/esm/shapes/TranslationKey.js.map +1 -0
  97. package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
  98. package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
  99. package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
  100. package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
  101. package/lib/esm/shapes/TranslationLanguage.js +102 -0
  102. package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
  103. package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
  104. package/lib/esm/shapes/TranslationProvider.js +1376 -0
  105. package/lib/esm/shapes/TranslationProvider.js.map +1 -0
  106. package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
  107. package/lib/esm/shapes/TranslationRelease.js +149 -0
  108. package/lib/esm/shapes/TranslationRelease.js.map +1 -0
  109. package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
  110. package/lib/esm/shapes/TranslationRevision.js +159 -0
  111. package/lib/esm/shapes/TranslationRevision.js.map +1 -0
  112. package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
  113. package/lib/esm/shapes/TranslationUnit.js +111 -0
  114. package/lib/esm/shapes/TranslationUnit.js.map +1 -0
  115. package/package.json +134 -0
  116. package/src/archive.ts +894 -0
  117. package/src/authorization.ts +33 -0
  118. package/src/backend.ts +2 -0
  119. package/src/cdn-loader.ts +264 -0
  120. package/src/checks.ts +413 -0
  121. package/src/compile.ts +168 -0
  122. package/src/core/messages.ts +106 -0
  123. package/src/data/translation.json +8 -0
  124. package/src/discovery.ts +594 -0
  125. package/src/exchange.ts +428 -0
  126. package/src/export.ts +135 -0
  127. package/src/formats/csv.ts +459 -0
  128. package/src/formats/json.ts +587 -0
  129. package/src/formats/xliff.ts +708 -0
  130. package/src/import.ts +648 -0
  131. package/src/index.ts +207 -0
  132. package/src/key-sync-node.ts +269 -0
  133. package/src/key-sync.ts +168 -0
  134. package/src/key-version.ts +206 -0
  135. package/src/languages.ts +102 -0
  136. package/src/linked-discovery.ts +385 -0
  137. package/src/message-format.ts +214 -0
  138. package/src/mt.ts +248 -0
  139. package/src/ontologies/translation.ts +234 -0
  140. package/src/package.ts +19 -0
  141. package/src/react.tsx +250 -0
  142. package/src/records.ts +180 -0
  143. package/src/release.ts +726 -0
  144. package/src/shape-catalog.ts +198 -0
  145. package/src/shapes/GlossaryTerm.ts +61 -0
  146. package/src/shapes/TranslationInventoryOrigin.ts +131 -0
  147. package/src/shapes/TranslationKey.ts +174 -0
  148. package/src/shapes/TranslationKeyVersion.ts +77 -0
  149. package/src/shapes/TranslationLanguage.ts +51 -0
  150. package/src/shapes/TranslationProvider.ts +1755 -0
  151. package/src/shapes/TranslationRelease.ts +114 -0
  152. package/src/shapes/TranslationRevision.ts +105 -0
  153. package/src/shapes/TranslationUnit.ts +72 -0
package/src/archive.ts ADDED
@@ -0,0 +1,894 @@
1
+ import { zipSync, type Zippable } from 'fflate';
2
+
3
+ import { canonicalJson } from './release.js';
4
+ import {
5
+ DEFAULT_TRANSLATION_JSON_ARCHIVE_LIMITS,
6
+ safeUnzipTranslationZip,
7
+ translationZipLimits,
8
+ type TranslationJsonArchiveLimits,
9
+ } from './formats/json.js';
10
+
11
+ const encoder = new TextEncoder();
12
+ const decoder = new TextDecoder('utf-8', { fatal: true });
13
+
14
+ /** Full backups include every language and its revision history. CAT-file
15
+ * imports retain their smaller defaults; native backups remain bounded too. */
16
+ export const DEFAULT_TRANSLATION_ARCHIVE_LIMITS: TranslationJsonArchiveLimits =
17
+ Object.freeze({
18
+ ...DEFAULT_TRANSLATION_JSON_ARCHIVE_LIMITS,
19
+ maxArchiveBytes: 64 * 1024 * 1024,
20
+ maxEntryCompressedBytes: 32 * 1024 * 1024,
21
+ maxEntryUncompressedBytes: 128 * 1024 * 1024,
22
+ maxTotalUncompressedBytes: 256 * 1024 * 1024,
23
+ });
24
+
25
+ export const TRANSLATION_ARCHIVE_MANIFEST_PATH =
26
+ 'create-now.translation-archive.json';
27
+
28
+ export type TranslationArchiveCollection =
29
+ | 'configuration'
30
+ | 'keys'
31
+ | 'keyVersions'
32
+ | 'units'
33
+ | 'glossary'
34
+ | 'style'
35
+ | 'revisions'
36
+ | 'provenance'
37
+ | 'content'
38
+ | 'releases'
39
+ | 'buildPins';
40
+
41
+ export const TRANSLATION_ARCHIVE_COLLECTIONS:
42
+ readonly TranslationArchiveCollection[] = Object.freeze([
43
+ 'configuration',
44
+ 'keys',
45
+ 'keyVersions',
46
+ 'units',
47
+ 'glossary',
48
+ 'style',
49
+ 'revisions',
50
+ 'provenance',
51
+ 'content',
52
+ 'releases',
53
+ 'buildPins',
54
+ ]);
55
+
56
+ export const TRANSLATION_ARCHIVE_EXCLUSIONS = Object.freeze([
57
+ 'provider secrets and API keys',
58
+ 'import/export sessions',
59
+ 'collaborators, assignments, and invitations',
60
+ 'workspace entitlements',
61
+ 'identity and authentication records',
62
+ 'unrelated application graph data',
63
+ ]);
64
+
65
+ export type TranslationArchiveJson =
66
+ | null
67
+ | boolean
68
+ | number
69
+ | string
70
+ | TranslationArchiveJson[]
71
+ | { [key: string]: TranslationArchiveJson };
72
+
73
+ export interface TranslationArchiveEntityInput {
74
+ id: string;
75
+ data: Record<string, TranslationArchiveJson>;
76
+ }
77
+
78
+ export interface TranslationArchiveEntity extends TranslationArchiveEntityInput {
79
+ contentHash: string;
80
+ }
81
+
82
+ export interface TranslationArchiveObjectInput {
83
+ hash: string;
84
+ body?: Uint8Array;
85
+ mediaType?: string;
86
+ }
87
+
88
+ export interface TranslationArchiveSnapshot {
89
+ appId: string;
90
+ branchId: string;
91
+ revisionWatermark: string;
92
+ createdAt?: string;
93
+ collections: Partial<
94
+ Record<TranslationArchiveCollection, TranslationArchiveEntityInput[]>
95
+ >;
96
+ /** Immutable release catalog objects; omitted bodies remain references only. */
97
+ objects?: TranslationArchiveObjectInput[];
98
+ }
99
+
100
+ export interface TranslationArchiveFileInventory {
101
+ path: string;
102
+ collection: TranslationArchiveCollection;
103
+ hash: string;
104
+ bytes: number;
105
+ records: number;
106
+ }
107
+
108
+ export interface TranslationArchiveObjectInventory {
109
+ path: string;
110
+ hash: string;
111
+ bytes?: number;
112
+ mediaType?: string;
113
+ included: boolean;
114
+ }
115
+
116
+ export interface TranslationArchiveManifest {
117
+ schemaVersion: 1;
118
+ format: 'create-now-translation-archive';
119
+ appId: string;
120
+ branchId: string;
121
+ revisionWatermark: string;
122
+ createdAt?: string;
123
+ files: TranslationArchiveFileInventory[];
124
+ objects: TranslationArchiveObjectInventory[];
125
+ exclusions: string[];
126
+ archiveContentHash: string;
127
+ }
128
+
129
+ export interface SerializedTranslationArchive {
130
+ fileName: string;
131
+ mediaType: 'application/zip';
132
+ body: Uint8Array;
133
+ manifest: TranslationArchiveManifest;
134
+ }
135
+
136
+ export interface ParsedTranslationArchive {
137
+ manifest: TranslationArchiveManifest;
138
+ collections: Record<
139
+ TranslationArchiveCollection,
140
+ TranslationArchiveEntity[]
141
+ >;
142
+ objects: Array<{
143
+ hash: string;
144
+ body?: Uint8Array;
145
+ mediaType?: string;
146
+ }>;
147
+ }
148
+
149
+ export interface TranslationArchiveTargetRecord {
150
+ id: string;
151
+ contentHash: string;
152
+ }
153
+
154
+ export interface TranslationArchiveContentMapping {
155
+ sourceNodeIri: string;
156
+ sourceFieldIri: string;
157
+ targetNodeIri: string;
158
+ targetFieldIri: string;
159
+ }
160
+
161
+ export interface PlanTranslationArchiveRestoreOptions {
162
+ mode: 'same-app' | 'cross-app';
163
+ targetAppId: string;
164
+ targetRecords?: TranslationArchiveTargetRecord[];
165
+ contentMappings?: TranslationArchiveContentMapping[];
166
+ targetRevisionWatermark?: string;
167
+ }
168
+
169
+ export interface TranslationArchiveRestoreDecision {
170
+ collection: TranslationArchiveCollection;
171
+ sourceId: string;
172
+ targetId: string;
173
+ action: 'create' | 'skip' | 'collision' | 'unmapped';
174
+ expectedTargetContentHash?: string;
175
+ record?: TranslationArchiveEntity;
176
+ reason?: string;
177
+ retryKey: string;
178
+ }
179
+
180
+ export interface TranslationArchiveRestorePlan {
181
+ schemaVersion: 1;
182
+ mode: 'same-app' | 'cross-app';
183
+ sourceAppId: string;
184
+ targetAppId: string;
185
+ sourceBranchId: string;
186
+ archiveContentHash: string;
187
+ archiveRevisionWatermark: string;
188
+ expectedTargetRevisionWatermark?: string;
189
+ blocked: boolean;
190
+ decisions: TranslationArchiveRestoreDecision[];
191
+ iriMappings: Record<string, string>;
192
+ creates: number;
193
+ skips: number;
194
+ collisions: number;
195
+ unresolvedContent: number;
196
+ historicalReleases: number;
197
+ identityWrites: 0;
198
+ assignmentWrites: 0;
199
+ }
200
+
201
+ const COLLECTION_PATHS: Record<TranslationArchiveCollection, string> = {
202
+ configuration: 'content/configuration.json',
203
+ keys: 'content/keys.json',
204
+ keyVersions: 'content/key-versions.json',
205
+ units: 'content/units.json',
206
+ glossary: 'content/glossary.json',
207
+ style: 'content/style.json',
208
+ revisions: 'content/revisions.json',
209
+ provenance: 'content/provenance.json',
210
+ content: 'content/content-translations.json',
211
+ releases: 'content/releases.json',
212
+ buildPins: 'content/build-pins.json',
213
+ };
214
+
215
+ const FORBIDDEN_ARCHIVE_FIELDS = new Set([
216
+ 'apikey',
217
+ 'api_key',
218
+ 'secret',
219
+ 'clientsecret',
220
+ 'accesstoken',
221
+ 'refreshtoken',
222
+ 'password',
223
+ 'credential',
224
+ 'credentials',
225
+ 'session',
226
+ 'sessionid',
227
+ 'collaborator',
228
+ 'collaborators',
229
+ 'invite',
230
+ 'invites',
231
+ 'assignment',
232
+ 'assignments',
233
+ 'entitlement',
234
+ 'entitlements',
235
+ ]);
236
+
237
+ function requiredString(value: unknown, label: string): string {
238
+ if (typeof value !== 'string' || !value.trim()) {
239
+ throw new Error(`${label} is required.`);
240
+ }
241
+ return value.trim();
242
+ }
243
+
244
+ function record(value: unknown, label: string): Record<string, unknown> {
245
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
246
+ throw new Error(`${label} must be an object.`);
247
+ }
248
+ return value as Record<string, unknown>;
249
+ }
250
+
251
+ function assertArchiveSafeValue(value: unknown, path = 'data'): void {
252
+ if (
253
+ value === null ||
254
+ typeof value === 'string' ||
255
+ typeof value === 'boolean'
256
+ ) {
257
+ return;
258
+ }
259
+ if (typeof value === 'number') {
260
+ if (!Number.isFinite(value)) {
261
+ throw new Error(`${path} contains a non-finite number.`);
262
+ }
263
+ return;
264
+ }
265
+ if (Array.isArray(value)) {
266
+ value.forEach((item, index) =>
267
+ assertArchiveSafeValue(item, `${path}[${index}]`),
268
+ );
269
+ return;
270
+ }
271
+ if (!value || typeof value !== 'object') {
272
+ throw new Error(`${path} contains an unsupported value.`);
273
+ }
274
+ for (const [key, item] of Object.entries(value)) {
275
+ if (FORBIDDEN_ARCHIVE_FIELDS.has(key.toLowerCase())) {
276
+ throw new Error(
277
+ `Archive field "${path}.${key}" contains excluded security or control-plane data.`,
278
+ );
279
+ }
280
+ assertArchiveSafeValue(item, `${path}.${key}`);
281
+ }
282
+ }
283
+
284
+ function decodeJson(input: Uint8Array, label: string): unknown {
285
+ try {
286
+ return JSON.parse(decoder.decode(input)) as unknown;
287
+ } catch {
288
+ throw new Error(`${label} must contain valid UTF-8 JSON.`);
289
+ }
290
+ }
291
+
292
+ async function sha256Bytes(value: Uint8Array): Promise<string> {
293
+ const digest = await globalThis.crypto.subtle.digest(
294
+ 'SHA-256',
295
+ value as unknown as BufferSource,
296
+ );
297
+ return [...new Uint8Array(digest)]
298
+ .map((byte) => byte.toString(16).padStart(2, '0'))
299
+ .join('');
300
+ }
301
+
302
+ function canonicalBytes(value: unknown): Uint8Array {
303
+ return encoder.encode(canonicalJson(value));
304
+ }
305
+
306
+ function normalizeEntityInput(
307
+ value: TranslationArchiveEntityInput,
308
+ label: string,
309
+ ): TranslationArchiveEntityInput {
310
+ const id = requiredString(value?.id, `${label}.id`);
311
+ const data = record(value?.data, `${label}.data`) as Record<
312
+ string,
313
+ TranslationArchiveJson
314
+ >;
315
+ assertArchiveSafeValue(data, `${label}.data`);
316
+ return { id, data };
317
+ }
318
+
319
+ async function archiveEntity(
320
+ value: TranslationArchiveEntityInput,
321
+ label: string,
322
+ ): Promise<TranslationArchiveEntity> {
323
+ const normalized = normalizeEntityInput(value, label);
324
+ return {
325
+ ...normalized,
326
+ contentHash: await sha256Bytes(canonicalBytes(normalized)),
327
+ };
328
+ }
329
+
330
+ function archiveContentHashInput(
331
+ manifest: Omit<TranslationArchiveManifest, 'archiveContentHash'>,
332
+ ): unknown {
333
+ return {
334
+ schemaVersion: manifest.schemaVersion,
335
+ format: manifest.format,
336
+ appId: manifest.appId,
337
+ branchId: manifest.branchId,
338
+ revisionWatermark: manifest.revisionWatermark,
339
+ ...(manifest.createdAt ? { createdAt: manifest.createdAt } : {}),
340
+ files: manifest.files,
341
+ objects: manifest.objects,
342
+ exclusions: manifest.exclusions,
343
+ };
344
+ }
345
+
346
+ function objectPath(hash: string): string {
347
+ return `objects/catalog/${hash}.json`;
348
+ }
349
+
350
+ function normalizeHash(value: unknown, label: string): string {
351
+ const hash = requiredString(value, label).toLowerCase();
352
+ if (!/^[a-f0-9]{64}$/.test(hash)) {
353
+ throw new Error(`${label} must be a SHA-256 hex digest.`);
354
+ }
355
+ return hash;
356
+ }
357
+
358
+ /** Create a deterministic, full-fidelity Create Now translation archive. */
359
+ export async function createTranslationArchive(
360
+ snapshot: TranslationArchiveSnapshot,
361
+ options: { limits?: Partial<TranslationJsonArchiveLimits> } = {},
362
+ ): Promise<SerializedTranslationArchive> {
363
+ const appId = requiredString(snapshot?.appId, 'snapshot.appId');
364
+ const branchId = requiredString(snapshot?.branchId, 'snapshot.branchId');
365
+ const revisionWatermark = requiredString(
366
+ snapshot?.revisionWatermark,
367
+ 'snapshot.revisionWatermark',
368
+ );
369
+ const limits = translationZipLimits({
370
+ ...DEFAULT_TRANSLATION_ARCHIVE_LIMITS,
371
+ ...options.limits,
372
+ });
373
+ const files: Zippable = Object.create(null);
374
+ const fileInventory: TranslationArchiveFileInventory[] = [];
375
+ for (const collection of TRANSLATION_ARCHIVE_COLLECTIONS) {
376
+ const values = snapshot.collections?.[collection] ?? [];
377
+ const entities = await Promise.all(
378
+ values.map((value, index) =>
379
+ archiveEntity(value, `${collection}[${index}]`),
380
+ ),
381
+ );
382
+ entities.sort((left, right) => left.id.localeCompare(right.id));
383
+ const duplicate = entities.find(
384
+ (entity, index) => index > 0 && entity.id === entities[index - 1].id,
385
+ );
386
+ if (duplicate) {
387
+ throw new Error(
388
+ `Archive collection "${collection}" contains duplicate id "${duplicate.id}".`,
389
+ );
390
+ }
391
+ const path = COLLECTION_PATHS[collection];
392
+ const body = canonicalBytes(entities);
393
+ files[path] = body;
394
+ fileInventory.push({
395
+ path,
396
+ collection,
397
+ hash: await sha256Bytes(body),
398
+ bytes: body.byteLength,
399
+ records: entities.length,
400
+ });
401
+ }
402
+
403
+ const objectInventory: TranslationArchiveObjectInventory[] = [];
404
+ const seenObjects = new Set<string>();
405
+ for (const [index, object] of [...(snapshot.objects ?? [])]
406
+ .sort((left, right) => left.hash.localeCompare(right.hash))
407
+ .entries()) {
408
+ const hash = normalizeHash(object.hash, `objects[${index}].hash`);
409
+ if (seenObjects.has(hash)) {
410
+ throw new Error(`Archive contains duplicate object hash "${hash}".`);
411
+ }
412
+ seenObjects.add(hash);
413
+ const path = objectPath(hash);
414
+ if (object.body) {
415
+ const actual = await sha256Bytes(object.body);
416
+ if (actual !== hash) {
417
+ throw new Error(
418
+ `Archive object "${hash}" does not match its content hash.`,
419
+ );
420
+ }
421
+ files[path] = object.body;
422
+ }
423
+ objectInventory.push({
424
+ path,
425
+ hash,
426
+ included: Boolean(object.body),
427
+ ...(object.body ? { bytes: object.body.byteLength } : {}),
428
+ ...(object.mediaType ? { mediaType: object.mediaType } : {}),
429
+ });
430
+ }
431
+
432
+ const unsignedManifest: Omit<
433
+ TranslationArchiveManifest,
434
+ 'archiveContentHash'
435
+ > = {
436
+ schemaVersion: 1,
437
+ format: 'create-now-translation-archive',
438
+ appId,
439
+ branchId,
440
+ revisionWatermark,
441
+ ...(snapshot.createdAt
442
+ ? { createdAt: requiredString(snapshot.createdAt, 'snapshot.createdAt') }
443
+ : {}),
444
+ files: fileInventory,
445
+ objects: objectInventory,
446
+ exclusions: [...TRANSLATION_ARCHIVE_EXCLUSIONS],
447
+ };
448
+ const manifest: TranslationArchiveManifest = {
449
+ ...unsignedManifest,
450
+ archiveContentHash: await sha256Bytes(
451
+ canonicalBytes(archiveContentHashInput(unsignedManifest)),
452
+ ),
453
+ };
454
+ files[TRANSLATION_ARCHIVE_MANIFEST_PATH] = canonicalBytes(manifest);
455
+ if (Object.keys(files).length > limits.maxEntries) {
456
+ throw new Error('ZIP archive exceeds the entry-count limit.');
457
+ }
458
+ const body = zipSync(files, {
459
+ level: 6,
460
+ mtime: new Date('1980-01-01T00:00:00.000Z'),
461
+ });
462
+ safeUnzipTranslationZip(body, limits);
463
+ return {
464
+ fileName: 'create-now-translations.backup.zip',
465
+ mediaType: 'application/zip',
466
+ body,
467
+ manifest,
468
+ };
469
+ }
470
+
471
+ function parseManifest(value: unknown): TranslationArchiveManifest {
472
+ const raw = record(value, 'manifest');
473
+ if (raw.schemaVersion !== 1) {
474
+ throw new Error(
475
+ `Unsupported translation archive schemaVersion "${String(raw.schemaVersion)}".`,
476
+ );
477
+ }
478
+ if (raw.format !== 'create-now-translation-archive') {
479
+ throw new Error('Unsupported translation archive format.');
480
+ }
481
+ if (!Array.isArray(raw.files) || !Array.isArray(raw.objects)) {
482
+ throw new Error('Translation archive manifest inventory is invalid.');
483
+ }
484
+ if (
485
+ !Array.isArray(raw.exclusions) ||
486
+ raw.exclusions.some((item) => typeof item !== 'string')
487
+ ) {
488
+ throw new Error('Translation archive exclusions are invalid.');
489
+ }
490
+ const collectionSet = new Set(TRANSLATION_ARCHIVE_COLLECTIONS);
491
+ const files = raw.files.map((value, index) => {
492
+ const item = record(value, `manifest.files[${index}]`);
493
+ const collection = item.collection as TranslationArchiveCollection;
494
+ if (!collectionSet.has(collection)) {
495
+ throw new Error(`manifest.files[${index}].collection is invalid.`);
496
+ }
497
+ if (item.path !== COLLECTION_PATHS[collection]) {
498
+ throw new Error(`manifest.files[${index}].path is invalid.`);
499
+ }
500
+ if (
501
+ !Number.isSafeInteger(item.bytes) ||
502
+ (item.bytes as number) < 0 ||
503
+ !Number.isSafeInteger(item.records) ||
504
+ (item.records as number) < 0
505
+ ) {
506
+ throw new Error(`manifest.files[${index}] counts are invalid.`);
507
+ }
508
+ return {
509
+ path: item.path as string,
510
+ collection,
511
+ hash: normalizeHash(item.hash, `manifest.files[${index}].hash`),
512
+ bytes: item.bytes as number,
513
+ records: item.records as number,
514
+ };
515
+ });
516
+ const objects = raw.objects.map((value, index) => {
517
+ const item = record(value, `manifest.objects[${index}]`);
518
+ const hash = normalizeHash(item.hash, `manifest.objects[${index}].hash`);
519
+ if (item.path !== objectPath(hash) || typeof item.included !== 'boolean') {
520
+ throw new Error(`manifest.objects[${index}] is invalid.`);
521
+ }
522
+ if (
523
+ item.bytes !== undefined &&
524
+ (!Number.isSafeInteger(item.bytes) || (item.bytes as number) < 0)
525
+ ) {
526
+ throw new Error(`manifest.objects[${index}].bytes is invalid.`);
527
+ }
528
+ return {
529
+ path: item.path as string,
530
+ hash,
531
+ included: item.included,
532
+ ...(item.bytes === undefined ? {} : { bytes: item.bytes as number }),
533
+ ...(item.mediaType === undefined
534
+ ? {}
535
+ : { mediaType: requiredString(item.mediaType, `manifest.objects[${index}].mediaType`) }),
536
+ };
537
+ });
538
+ return {
539
+ schemaVersion: 1,
540
+ format: 'create-now-translation-archive',
541
+ appId: requiredString(raw.appId, 'manifest.appId'),
542
+ branchId: requiredString(raw.branchId, 'manifest.branchId'),
543
+ revisionWatermark: requiredString(
544
+ raw.revisionWatermark,
545
+ 'manifest.revisionWatermark',
546
+ ),
547
+ ...(raw.createdAt === undefined
548
+ ? {}
549
+ : { createdAt: requiredString(raw.createdAt, 'manifest.createdAt') }),
550
+ files,
551
+ objects,
552
+ exclusions: raw.exclusions as string[],
553
+ archiveContentHash: normalizeHash(
554
+ raw.archiveContentHash,
555
+ 'manifest.archiveContentHash',
556
+ ),
557
+ };
558
+ }
559
+
560
+ async function parseEntities(
561
+ value: unknown,
562
+ collection: TranslationArchiveCollection,
563
+ ): Promise<TranslationArchiveEntity[]> {
564
+ if (!Array.isArray(value)) {
565
+ throw new Error(`Archive ${collection} file must contain an array.`);
566
+ }
567
+ const entities: TranslationArchiveEntity[] = [];
568
+ const ids = new Set<string>();
569
+ for (const [index, rawValue] of value.entries()) {
570
+ const raw = record(rawValue, `${collection}[${index}]`);
571
+ const normalized = normalizeEntityInput(
572
+ {
573
+ id: raw.id as string,
574
+ data: raw.data as Record<string, TranslationArchiveJson>,
575
+ },
576
+ `${collection}[${index}]`,
577
+ );
578
+ const contentHash = normalizeHash(
579
+ raw.contentHash,
580
+ `${collection}[${index}].contentHash`,
581
+ );
582
+ const actual = await sha256Bytes(canonicalBytes(normalized));
583
+ if (actual !== contentHash) {
584
+ throw new Error(
585
+ `Archive ${collection} record "${normalized.id}" has an invalid content hash.`,
586
+ );
587
+ }
588
+ if (ids.has(normalized.id)) {
589
+ throw new Error(
590
+ `Archive collection "${collection}" contains duplicate id "${normalized.id}".`,
591
+ );
592
+ }
593
+ ids.add(normalized.id);
594
+ entities.push({ ...normalized, contentHash });
595
+ }
596
+ return entities.sort((left, right) => left.id.localeCompare(right.id));
597
+ }
598
+
599
+ /** Parse and verify every declared content file and optional catalog object. */
600
+ export async function parseTranslationArchive(
601
+ input: Uint8Array,
602
+ options: { limits?: Partial<TranslationJsonArchiveLimits> } = {},
603
+ ): Promise<ParsedTranslationArchive> {
604
+ const files = safeUnzipTranslationZip(input, {
605
+ ...DEFAULT_TRANSLATION_ARCHIVE_LIMITS,
606
+ ...options.limits,
607
+ });
608
+ const manifestBody = files[TRANSLATION_ARCHIVE_MANIFEST_PATH];
609
+ if (!manifestBody) {
610
+ throw new Error('Translation archive manifest is missing.');
611
+ }
612
+ const manifest = parseManifest(
613
+ decodeJson(manifestBody, TRANSLATION_ARCHIVE_MANIFEST_PATH),
614
+ );
615
+ const actualArchiveHash = await sha256Bytes(
616
+ canonicalBytes(
617
+ archiveContentHashInput(
618
+ manifest as Omit<TranslationArchiveManifest, 'archiveContentHash'>,
619
+ ),
620
+ ),
621
+ );
622
+ if (actualArchiveHash !== manifest.archiveContentHash) {
623
+ throw new Error('Translation archive manifest content hash is invalid.');
624
+ }
625
+
626
+ const allowedPaths = new Set<string>([
627
+ TRANSLATION_ARCHIVE_MANIFEST_PATH,
628
+ ...manifest.files.map(({ path }) => path),
629
+ ...manifest.objects.filter(({ included }) => included).map(({ path }) => path),
630
+ ]);
631
+ const unexpected = Object.keys(files).find((path) => !allowedPaths.has(path));
632
+ if (unexpected) {
633
+ throw new Error(`Translation archive contains undeclared file "${unexpected}".`);
634
+ }
635
+ if (manifest.files.length !== TRANSLATION_ARCHIVE_COLLECTIONS.length) {
636
+ throw new Error('Translation archive must inventory every content collection.');
637
+ }
638
+
639
+ const collections = Object.create(null) as Record<
640
+ TranslationArchiveCollection,
641
+ TranslationArchiveEntity[]
642
+ >;
643
+ for (const item of manifest.files) {
644
+ const body = files[item.path];
645
+ if (!body) throw new Error(`Translation archive is missing "${item.path}".`);
646
+ if (
647
+ body.byteLength !== item.bytes ||
648
+ (await sha256Bytes(body)) !== item.hash
649
+ ) {
650
+ throw new Error(`Translation archive file "${item.path}" failed verification.`);
651
+ }
652
+ const entities = await parseEntities(
653
+ decodeJson(body, item.path),
654
+ item.collection,
655
+ );
656
+ if (entities.length !== item.records) {
657
+ throw new Error(`Translation archive file "${item.path}" count is invalid.`);
658
+ }
659
+ collections[item.collection] = entities;
660
+ }
661
+
662
+ const objects = await Promise.all(
663
+ manifest.objects.map(async (item) => {
664
+ const body = files[item.path];
665
+ if (item.included) {
666
+ if (!body) {
667
+ throw new Error(`Translation archive object "${item.hash}" is missing.`);
668
+ }
669
+ if (
670
+ (item.bytes !== undefined && body.byteLength !== item.bytes) ||
671
+ (await sha256Bytes(body)) !== item.hash
672
+ ) {
673
+ throw new Error(
674
+ `Translation archive object "${item.hash}" failed verification.`,
675
+ );
676
+ }
677
+ } else if (body) {
678
+ throw new Error(
679
+ `Translation archive reference-only object "${item.hash}" unexpectedly has a body.`,
680
+ );
681
+ }
682
+ return {
683
+ hash: item.hash,
684
+ ...(body ? { body } : {}),
685
+ ...(item.mediaType ? { mediaType: item.mediaType } : {}),
686
+ };
687
+ }),
688
+ );
689
+ return { manifest, collections, objects };
690
+ }
691
+
692
+ function rebaseValue(
693
+ value: TranslationArchiveJson,
694
+ sourceAppId: string,
695
+ targetAppId: string,
696
+ mappings: Record<string, string>,
697
+ ): TranslationArchiveJson {
698
+ if (typeof value === 'string') {
699
+ if (value === sourceAppId || value.startsWith(`${sourceAppId}/`)) {
700
+ const target = `${targetAppId}${value.slice(sourceAppId.length)}`;
701
+ mappings[value] = target;
702
+ return target;
703
+ }
704
+ return value;
705
+ }
706
+ if (Array.isArray(value)) {
707
+ return value.map((item) =>
708
+ rebaseValue(item, sourceAppId, targetAppId, mappings),
709
+ );
710
+ }
711
+ if (value && typeof value === 'object') {
712
+ const output: Record<string, TranslationArchiveJson> = {};
713
+ for (const [key, item] of Object.entries(value)) {
714
+ output[key] = rebaseValue(
715
+ item,
716
+ sourceAppId,
717
+ targetAppId,
718
+ mappings,
719
+ );
720
+ }
721
+ return output;
722
+ }
723
+ return value;
724
+ }
725
+
726
+ function contentIdentity(
727
+ nodeIri: string,
728
+ fieldIri: string,
729
+ ): string {
730
+ return JSON.stringify([nodeIri, fieldIri]);
731
+ }
732
+
733
+ /**
734
+ * Build a pure, conditional restore plan. A blocked plan is never executable:
735
+ * collisions and unresolved cross-app content mappings must be resolved first.
736
+ */
737
+ export async function planTranslationArchiveRestore(
738
+ archive: ParsedTranslationArchive,
739
+ options: PlanTranslationArchiveRestoreOptions,
740
+ ): Promise<TranslationArchiveRestorePlan> {
741
+ const sourceAppId = archive.manifest.appId;
742
+ const targetAppId = requiredString(options.targetAppId, 'targetAppId');
743
+ if (options.mode === 'same-app' && sourceAppId !== targetAppId) {
744
+ throw new Error(
745
+ `Same-app restore requires target app "${sourceAppId}".`,
746
+ );
747
+ }
748
+ const targetById = new Map(
749
+ (options.targetRecords ?? []).map((item) => [
750
+ requiredString(item.id, 'targetRecords.id'),
751
+ normalizeHash(item.contentHash, 'targetRecords.contentHash'),
752
+ ]),
753
+ );
754
+ const contentMappings = new Map(
755
+ (options.contentMappings ?? []).map((mapping) => [
756
+ contentIdentity(mapping.sourceNodeIri, mapping.sourceFieldIri),
757
+ mapping,
758
+ ]),
759
+ );
760
+ const iriMappings: Record<string, string> = {};
761
+ const decisions: TranslationArchiveRestoreDecision[] = [];
762
+ let historicalReleases = 0;
763
+
764
+ for (const collection of TRANSLATION_ARCHIVE_COLLECTIONS) {
765
+ for (const sourceRecord of archive.collections[collection]) {
766
+ let targetId = sourceRecord.id;
767
+ let data = sourceRecord.data;
768
+ if (options.mode === 'cross-app') {
769
+ targetId = rebaseValue(
770
+ sourceRecord.id,
771
+ sourceAppId,
772
+ targetAppId,
773
+ iriMappings,
774
+ ) as string;
775
+ data = rebaseValue(
776
+ sourceRecord.data,
777
+ sourceAppId,
778
+ targetAppId,
779
+ iriMappings,
780
+ ) as Record<string, TranslationArchiveJson>;
781
+ }
782
+
783
+ if (collection === 'content' && options.mode === 'cross-app') {
784
+ const sourceNodeIri =
785
+ typeof sourceRecord.data.nodeIri === 'string'
786
+ ? sourceRecord.data.nodeIri
787
+ : '';
788
+ const sourceFieldIri =
789
+ typeof sourceRecord.data.fieldIri === 'string'
790
+ ? sourceRecord.data.fieldIri
791
+ : '';
792
+ const mapping = contentMappings.get(
793
+ contentIdentity(sourceNodeIri, sourceFieldIri),
794
+ );
795
+ if (!mapping) {
796
+ decisions.push({
797
+ collection,
798
+ sourceId: sourceRecord.id,
799
+ targetId,
800
+ action: 'unmapped',
801
+ reason: 'Cross-app content requires an explicit node/field mapping.',
802
+ retryKey: canonicalJson([
803
+ archive.manifest.archiveContentHash,
804
+ collection,
805
+ sourceRecord.id,
806
+ 'unmapped',
807
+ ]),
808
+ });
809
+ continue;
810
+ }
811
+ data = {
812
+ ...data,
813
+ nodeIri: mapping.targetNodeIri,
814
+ fieldIri: mapping.targetFieldIri,
815
+ };
816
+ iriMappings[sourceNodeIri] = mapping.targetNodeIri;
817
+ iriMappings[sourceFieldIri] = mapping.targetFieldIri;
818
+ }
819
+
820
+ if (collection === 'releases') {
821
+ historicalReleases += 1;
822
+ data = {
823
+ ...data,
824
+ status: 'retired',
825
+ historical: true,
826
+ activationEligible: false,
827
+ };
828
+ }
829
+ const targetRecordInput = { id: targetId, data };
830
+ const targetRecord: TranslationArchiveEntity = {
831
+ ...targetRecordInput,
832
+ contentHash: await sha256Bytes(canonicalBytes(targetRecordInput)),
833
+ };
834
+ const currentHash = targetById.get(targetId);
835
+ const action =
836
+ currentHash === undefined
837
+ ? 'create'
838
+ : currentHash === targetRecord.contentHash
839
+ ? 'skip'
840
+ : 'collision';
841
+ decisions.push({
842
+ collection,
843
+ sourceId: sourceRecord.id,
844
+ targetId,
845
+ action,
846
+ ...(currentHash ? { expectedTargetContentHash: currentHash } : {}),
847
+ record: targetRecord,
848
+ ...(action === 'collision'
849
+ ? { reason: 'Target id exists with different content.' }
850
+ : {}),
851
+ retryKey: canonicalJson([
852
+ archive.manifest.archiveContentHash,
853
+ collection,
854
+ targetId,
855
+ targetRecord.contentHash,
856
+ ]),
857
+ });
858
+ }
859
+ }
860
+
861
+ const collisions = decisions.filter(
862
+ ({ action }) => action === 'collision',
863
+ ).length;
864
+ const unresolvedContent = decisions.filter(
865
+ ({ action }) => action === 'unmapped',
866
+ ).length;
867
+ return {
868
+ schemaVersion: 1,
869
+ mode: options.mode,
870
+ sourceAppId,
871
+ targetAppId,
872
+ sourceBranchId: archive.manifest.branchId,
873
+ archiveContentHash: archive.manifest.archiveContentHash,
874
+ archiveRevisionWatermark: archive.manifest.revisionWatermark,
875
+ ...(options.targetRevisionWatermark
876
+ ? {
877
+ expectedTargetRevisionWatermark: requiredString(
878
+ options.targetRevisionWatermark,
879
+ 'targetRevisionWatermark',
880
+ ),
881
+ }
882
+ : {}),
883
+ blocked: collisions > 0 || unresolvedContent > 0,
884
+ decisions,
885
+ iriMappings,
886
+ creates: decisions.filter(({ action }) => action === 'create').length,
887
+ skips: decisions.filter(({ action }) => action === 'skip').length,
888
+ collisions,
889
+ unresolvedContent,
890
+ historicalReleases,
891
+ identityWrites: 0,
892
+ assignmentWrites: 0,
893
+ };
894
+ }