Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Persist state #136

Merged
merged 20 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions webapp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ npm-debug.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# Lyra
store.json
45 changes: 0 additions & 45 deletions webapp/src/Cache.ts

This file was deleted.

4 changes: 2 additions & 2 deletions webapp/src/RepoGit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Octokit } from '@octokit/rest';
import path from 'path';
import { stringify } from 'yaml';

import { Cache } from '@/Cache';
import { IGit } from '@/utils/git/IGit';
import { LyraConfig } from '@/utils/lyraConfig';
import packageJson from '../package.json';
Expand All @@ -15,6 +14,7 @@ import { debug, info, warn } from '@/utils/log';
import { WriteLanguageFileError, WriteLanguageFileErrors } from '@/errors';
import { type TranslationMap } from '@/utils/adapters';
import { getTranslationsBySourceFile } from '@/utils/translationObjectUtil';
import { Store } from '@/store/Store';

export class RepoGit {
private static repositories: {
Expand Down Expand Up @@ -83,7 +83,7 @@ export class RepoGit {
public async saveLanguageFiles(projectPath: string): Promise<string[]> {
const lyraConfig = await this.getLyraConfig();
const projectConfig = lyraConfig.getProjectConfigByPath(projectPath);
const projectStore = await Cache.getProjectStore(projectConfig);
const projectStore = await Store.getProjectStore(projectConfig);
const languages = await projectStore.getLanguageData();

return await this.writeLangFiles(
Expand Down
5 changes: 3 additions & 2 deletions webapp/src/actions/updateTranslation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use server';

import { Cache } from '@/Cache';
import { RepoGit } from '@/RepoGit';
import { Store } from '@/store/Store';
import { ServerConfig } from '@/utils/serverConfig';
import { MessageNotFound } from '@/errors';

Expand Down Expand Up @@ -78,7 +78,7 @@ export default async function updateTranslation(
};
}

const projectStore = await Cache.getProjectStore(projectConfig);
const projectStore = await Store.getProjectStore(projectConfig);

const messages = await projectStore.getMessages();
const messageIds = messages.map((message) => message.id);
Expand All @@ -90,6 +90,7 @@ export default async function updateTranslation(

try {
await projectStore.updateTranslation(languageName, messageId, translation);
await Store.persistToDisk();
} catch (e) {
return {
errorMessage: 'Failed to update translation',
Expand Down
19 changes: 12 additions & 7 deletions webapp/src/dataAccess.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Cache } from '@/Cache';
import { RepoGit } from '@/RepoGit';
import { ServerConfig, ServerProjectConfig } from '@/utils/serverConfig';
import { getTranslationsIdText } from './utils/translationObjectUtil';
import { LanguageNotSupported } from './errors';
import { Store } from '@/store/Store';

export async function accessProjects() {
const serverConfig = await ServerConfig.read();
Expand Down Expand Up @@ -41,12 +42,16 @@ export async function accessLanguage(
await repoGit.checkoutBaseAndPull();
const lyraConfig = await repoGit.getLyraConfig();
const projectConfig = lyraConfig.getProjectConfigByPath(project.projectPath);
const projectStore = await Cache.getProjectStore(projectConfig);
const projectStore = await Store.getProjectStore(projectConfig);
const messages = await projectStore.getMessages();
const translationsWithFilePath = await Cache.getLanguage(
projectName,
languageName,
);

if (!projectConfig.isLanguageSupported(languageName)) {
throw new LanguageNotSupported(languageName, projectName);
}

const translationsWithFilePath =
await projectStore.getTranslations(languageName);

const translations = getTranslationsIdText(translationsWithFilePath);

return {
Expand All @@ -61,7 +66,7 @@ async function readProject(project: ServerProjectConfig) {
await repoGit.checkoutBaseAndPull();
const lyraConfig = await repoGit.getLyraConfig();
const projectConfig = lyraConfig.getProjectConfigByPath(project.projectPath);
const store = await Cache.getProjectStore(projectConfig);
const store = await Store.getProjectStore(projectConfig);
const messages = await store.getMessages();
const languagesWithTranslations = projectConfig.languages.map(
async (lang) => {
Expand Down
6 changes: 0 additions & 6 deletions webapp/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
export class LanguageNotFound extends Error {
constructor(lang: string) {
super(`Language ${lang} not found`);
}
}

export class LanguageNotSupported extends Error {
constructor(lang: string, projectName: string) {
super(`Language ${lang} is not supported in project ${projectName}`);
Expand Down
30 changes: 3 additions & 27 deletions webapp/src/store/ProjectStore.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, it, jest } from '@jest/globals';

import { ProjectStore } from './ProjectStore';
import { LanguageNotFound } from '@/errors';
import { IMessageAdapter } from '@/utils/adapters';

function mockMsgAdapter(): jest.Mocked<IMessageAdapter> {
Expand Down Expand Up @@ -58,16 +57,16 @@ describe('ProjectStore', () => {
});
});

it('throws exception for missing language', async () => {
it('returns empty object for missing language', async () => {
expect.assertions(1);
const msgAdapter = mockMsgAdapter();
msgAdapter.getMessages.mockResolvedValue([]);
const projectStore = new ProjectStore(msgAdapter, {
getTranslations: async () => ({}),
});

const actual = projectStore.getTranslations('fi');
await expect(actual).rejects.toThrowError(LanguageNotFound);
const actual = await projectStore.getTranslations('fi');
expect(actual).toEqual({});
});

it('returns updated translations', async () => {
Expand Down Expand Up @@ -141,29 +140,6 @@ describe('ProjectStore', () => {
});
});

it('throws exception for missing language', async () => {
expect.assertions(1);
const msgAdapter = mockMsgAdapter();
msgAdapter.getMessages.mockResolvedValue([
{
defaultMessage: '',
id: 'greeting.headline',
params: [],
},
]);
const projectStore = new ProjectStore(msgAdapter, {
getTranslations: async () => ({}),
});

const actual = projectStore.updateTranslation(
'de',
'greeting.headline',
'Hallo!',
);

await expect(actual).rejects.toThrowError(LanguageNotFound);
});

it('gives full access to all languages', async () => {
const msgAdapter = mockMsgAdapter();
msgAdapter.getMessages.mockResolvedValue([
Expand Down
15 changes: 8 additions & 7 deletions webapp/src/store/ProjectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
MessageMap,
TranslationMap,
} from '@/utils/adapters';
import { LanguageNotFound } from '@/errors';
import { StoreData } from './types';
import mergeStoreData from './mergeStoreData';

Expand All @@ -17,8 +16,9 @@ export class ProjectStore {
constructor(
messageAdapter: IMessageAdapter,
translationAdapter: ITranslationAdapter,
initialState?: StoreData,
) {
this.data = {
this.data = initialState || {
languages: {},
messages: [],
};
Expand All @@ -42,12 +42,9 @@ export class ProjectStore {
await this.refresh();

const language = this.data.languages[lang];
if (!language) {
throw new LanguageNotFound(lang);
}

const output: MessageMap = {};
Object.entries(language).forEach(([key, messageTranslation]) => {
Object.entries(language ?? {}).forEach(([key, messageTranslation]) => {
output[key] = { ...messageTranslation };
});

Expand All @@ -59,11 +56,15 @@ export class ProjectStore {
return this.data.messages;
}

toJSON(): StoreData {
return this.data;
}

async updateTranslation(lang: string, id: string, text: string) {
await this.refresh();

if (!this.data.languages[lang]) {
throw new LanguageNotFound(lang);
this.data.languages[lang] = {};
}

if (!this.data.languages[lang][id]) {
Expand Down
86 changes: 86 additions & 0 deletions webapp/src/store/Store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,84 @@
import fs from 'fs/promises';

import { ProjectStore } from '@/store/ProjectStore';
import MessageAdapterFactory from '@/utils/adapters/MessageAdapterFactory';
import YamlTranslationAdapter from '@/utils/adapters/YamlTranslationAdapter';
import { LyraProjectConfig } from '@/utils/lyraConfig';
import { StoreData } from './types';

const FILE_PATH = './store.json';

export class Store {
private data = new Map<string, ProjectStore>();
private initialState: Record<string, StoreData | undefined> = {};
private writes: Promise<void> = Promise.resolve();

public static async getProjectStore(
lyraProjectConfig: LyraProjectConfig,
): Promise<ProjectStore> {
/**
* As this function is not async,
* no concurrent task executes between checking and updating globalThis.
*/
function initialize(): Promise<Store> {
if (!globalThis.store) {
const newStore = new Store();
globalThis.store = newStore
.loadFromDisk()
// TODO: this catch will never happened, since loadFromDisk catch all
.catch((reason) => {
// Forget the promise, so that the next call will retry.
globalThis.store = null;
throw reason;
})
.then(() => newStore);
}
return globalThis.store;
}

const store = await initialize();

if (!store.hasProjectStore(lyraProjectConfig.absPath)) {
const initialProjectState = store.initialState[lyraProjectConfig.absPath];

const projectStore = new ProjectStore(
MessageAdapterFactory.createAdapter(lyraProjectConfig),
new YamlTranslationAdapter(lyraProjectConfig.absTranslationsPath),
initialProjectState,
);
store.addProjectStore(lyraProjectConfig.absPath, projectStore);
}

return store.getProjectStore(lyraProjectConfig.absPath);
}

public static async persistToDisk(): Promise<void> {
const store = await globalThis.store;
if (!store) {
return;
}

// Retain initial data if we have not opened a ProjectStore
const payload = {};
Object.assign(payload, store.initialState, store.toJSON());
WULCAN marked this conversation as resolved.
Show resolved Hide resolved

const json = JSON.stringify(payload);

// Enqueue the new write, as concurrent writeFile is unsafe.
store.writes = store.writes.finally(() => fs.writeFile(FILE_PATH, json));

// Await the end of the queue, which is our write, to resolve.
await store.writes;
}

public toJSON(): Record<string, unknown> {
const data: Record<string, StoreData> = {};
this.data.forEach((value, key) => {
data[key] = value.toJSON();
});

return data;
}

public addProjectStore(projectPath: string, projectStore: ProjectStore) {
this.data.set(projectPath, projectStore);
Expand All @@ -18,4 +95,13 @@ export class Store {
}
return projectStore;
}

public async loadFromDisk(): Promise<void> {
try {
const json = await fs.readFile(FILE_PATH);
this.initialState = JSON.parse(json.toString());
WULCAN marked this conversation as resolved.
Show resolved Hide resolved
} catch (err) {
// Do nothing. It's fine to start from scratch sometimes.
}
WULCAN marked this conversation as resolved.
Show resolved Hide resolved
}
}
24 changes: 24 additions & 0 deletions webapp/src/store/mergeStoreData.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,30 @@ describe('mergeStoreData()', () => {
const result = mergeStoreData(inMemory, fromRepo);
expect(result).toEqual(fromRepo);
});

it('should not return undefined if both memory and repo are falsy (undefined)', () => {
const inMemory: StoreData = {
languages: {
sv: {},
},
messages: [],
};

const fromRepo: StoreData = {
languages: {
sv: {},
},
messages: [mockMessage('any.message.id', 'Default message')],
};

const result = mergeStoreData(inMemory, fromRepo);
expect(result).toStrictEqual({
languages: {
sv: {},
},
messages: [mockMessage('any.message.id', 'Default message')],
});
});
});

const mockTranslation = (
Expand Down
Loading