@_linked/translation 0.9.4 → 0.9.5

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