Skip to content

Commit 7567586

Browse files
committed
feat: [v2] add compatibility check for v2 translations (#412)
1 parent ac96b35 commit 7567586

File tree

6 files changed

+147
-15
lines changed

6 files changed

+147
-15
lines changed

.changeset/kind-pans-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
---
4+
5+
feat: [v2] store compatibility versions in db

.changeset/strong-planes-float.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
'@blinkk/root': patch
4+
---
5+
6+
feat: [v2] add compatibility check for v2 translations

packages/root-cms/core/client.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import crypto from 'node:crypto';
2-
import {Plugin, RootConfig} from '@blinkk/root';
2+
import {RootConfig} from '@blinkk/root';
33
import {App} from 'firebase-admin/app';
44
import {
55
FieldValue,
@@ -8,7 +8,7 @@ import {
88
Timestamp,
99
WriteBatch,
1010
} from 'firebase-admin/firestore';
11-
import {CMSPlugin} from './plugin.js';
11+
import {CMSPlugin, getCmsPlugin} from './plugin.js';
1212

1313
export interface Doc<Fields = any> {
1414
/** The id of the doc, e.g. "Pages/foo-bar". */
@@ -956,15 +956,6 @@ export function isRichTextData(data: any) {
956956
);
957957
}
958958

959-
export function getCmsPlugin(rootConfig: RootConfig): CMSPlugin {
960-
const plugins: Plugin[] = rootConfig.plugins || [];
961-
const plugin = plugins.find((plugin) => plugin.name === 'root-cms');
962-
if (!plugin) {
963-
throw new Error('could not find root-cms plugin config in root.config.ts');
964-
}
965-
return plugin as CMSPlugin;
966-
}
967-
968959
/**
969960
* Walks the data tree and converts any array of objects into "array objects"
970961
* for storage in firestore.
@@ -1166,3 +1157,5 @@ export function parseDocId(docId: string) {
11661157
}
11671158
return {collection, slug};
11681159
}
1160+
1161+
export {getCmsPlugin};
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {RootConfig} from '@blinkk/root';
2+
import {FieldValue} from 'firebase-admin/firestore';
3+
import type {CMSPlugin} from './plugin.js';
4+
5+
/**
6+
* Runs compatibility checks to ensure the current version of root supports
7+
* any backwards-incompatible db changes.
8+
*
9+
* Compatibility versions are stored in the db in Projects/<projectId> under
10+
* the "compatibility" key. If the compatibility version is less than the
11+
* latest version, a backwards-friendly "migration" is done to ensure
12+
* compatibility on both the old and new versions.
13+
*/
14+
export async function runCompatibilityChecks(
15+
rootConfig: RootConfig,
16+
cmsPlugin: CMSPlugin
17+
) {
18+
const projectId = cmsPlugin.getConfig().id || 'default';
19+
const db = cmsPlugin.getFirestore();
20+
const projectConfigDocRef = db.doc(`Projects/${projectId}`);
21+
const projectConfigDoc = await projectConfigDocRef.get();
22+
const projectConfig = projectConfigDoc.data() || {};
23+
24+
const compatibilityVersions = projectConfig.compatibility || {};
25+
let versionsChanged = false;
26+
27+
const translationsVersion = compatibilityVersions.translations || 0;
28+
if (translationsVersion < 2) {
29+
await migrateTranslationsToV2(rootConfig, cmsPlugin);
30+
compatibilityVersions.translations = 2;
31+
versionsChanged = true;
32+
}
33+
34+
if (versionsChanged) {
35+
await projectConfigDocRef.update({compatibility: compatibilityVersions});
36+
console.log('[root cms] updated db compatibility');
37+
}
38+
}
39+
40+
/**
41+
* Migrates translations from the "v1" format to "v2".
42+
*/
43+
async function migrateTranslationsToV2(
44+
rootConfig: RootConfig,
45+
cmsPlugin: CMSPlugin
46+
) {
47+
if (rootConfig.experiments?.rootCmsDisableTranslationsToV2Check) {
48+
return;
49+
}
50+
51+
const projectId = cmsPlugin.getConfig().id || 'default';
52+
const db = cmsPlugin.getFirestore();
53+
const dbPath = `Projects/${projectId}/Translations`;
54+
const query = db.collection(dbPath);
55+
const querySnapshot = await query.get();
56+
if (querySnapshot.size === 0) {
57+
return;
58+
}
59+
60+
console.log('[root cms] updating translations v2 compatibility');
61+
const translationsMemories: Record<string, Record<string, any>> = {};
62+
querySnapshot.forEach((doc) => {
63+
const hash = doc.id;
64+
const translation = doc.data();
65+
const tags = translation.tags || [];
66+
delete translation.tags;
67+
for (const tag of tags) {
68+
if (tag.includes('/')) {
69+
const translationsMemoryId = tag.replaceAll('/', '--');
70+
translationsMemories[translationsMemoryId] ??= {};
71+
translationsMemories[translationsMemoryId][hash] = translation;
72+
}
73+
}
74+
});
75+
76+
const batch = db.batch();
77+
Object.entries(translationsMemories).forEach(
78+
([translationsMemoryId, strings]) => {
79+
const updates = {
80+
sys: {
81+
modifiedAt: FieldValue.serverTimestamp(),
82+
modifiedBy: 'root-cms-client',
83+
},
84+
strings: strings,
85+
};
86+
const draftRef = db.doc(
87+
`Projects/${projectId}/TranslationsMemory/draft/Translations/${translationsMemoryId}`
88+
);
89+
const publishedRef = db.doc(
90+
`Projects/${projectId}/TranslationsMemory/published/Translations/${translationsMemoryId}`
91+
);
92+
batch.set(draftRef, updates, {merge: true});
93+
batch.set(publishedRef, updates, {merge: true});
94+
const len = Object.keys(strings).length;
95+
console.log(
96+
`[root cms] saving ${len} string(s) to ${translationsMemoryId}...`
97+
);
98+
}
99+
);
100+
await batch.commit();
101+
console.log('[root cms] done migrating translations to v2');
102+
}

packages/root-cms/core/plugin.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Plugin,
99
Request,
1010
Response,
11+
RootConfig,
1112
Server,
1213
} from '@blinkk/root';
1314
import bodyParser from 'body-parser';
@@ -24,6 +25,7 @@ import sirv from 'sirv';
2425
import {generateTypes} from '../cli/generate-types.js';
2526
import {api} from './api.js';
2627
import {Action, RootCMSClient} from './client.js';
28+
import {runCompatibilityChecks} from './compatibility.js';
2729

2830
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2931

@@ -445,6 +447,19 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin {
445447
}
446448
},
447449

450+
hooks: {
451+
/**
452+
* Startup hook. On `dev` and `build` commands, the plugin does
453+
* compatibility checks.
454+
*/
455+
startup: async ({command, rootConfig}) => {
456+
if (command === 'dev' || command === 'build') {
457+
const cmsPlugin = getCmsPlugin(rootConfig);
458+
await runCompatibilityChecks(rootConfig, cmsPlugin);
459+
}
460+
},
461+
},
462+
448463
/**
449464
* Attaches CMS-specific middleware to the Root.js server.
450465
*/
@@ -597,3 +612,12 @@ function fileExists(filepath: string): Promise<boolean> {
597612
.then(() => true)
598613
.catch(() => false);
599614
}
615+
616+
export function getCmsPlugin(rootConfig: RootConfig): CMSPlugin {
617+
const plugins: Plugin[] = rootConfig.plugins || [];
618+
const plugin = plugins.find((plugin) => plugin.name === 'root-cms');
619+
if (!plugin) {
620+
throw new Error('could not find root-cms plugin config in root.config.ts');
621+
}
622+
return plugin as CMSPlugin;
623+
}

packages/root/src/core/config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import {HtmlPrettyOptions} from '../render/html-pretty.js';
44
import {Plugin} from './plugin.js';
55
import {RequestMiddleware} from './types.js';
66

7+
export interface RootExperimentsConfig {
8+
[name: string]: boolean | undefined;
9+
enableScriptAsync?: boolean;
10+
}
11+
712
export interface RootUserConfig {
813
/**
914
* Canonical domain the website will serve on. Useful for things like the
@@ -83,10 +88,7 @@ export interface RootUserConfig {
8388
/**
8489
* Experimental config options. Note: these are subject to change at any time.
8590
*/
86-
experiments?: {
87-
/** Whether to render `<script>` tags with `async`. */
88-
enableScriptAsync?: boolean;
89-
};
91+
experiments?: RootExperimentsConfig;
9092
}
9193

9294
export type RootConfig = RootUserConfig & {

0 commit comments

Comments
 (0)