Skip to content

Commit

Permalink
Merge pull request #136 from zetkin/issue-30/persist-state
Browse files Browse the repository at this point in the history
Persist state
  • Loading branch information
WULCAN authored Nov 24, 2024
2 parents 8d003ad + c088c48 commit ff72fc1
Show file tree
Hide file tree
Showing 12 changed files with 154 additions and 98 deletions.
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());

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());
} catch (err) {
// Do nothing. It's fine to start from scratch sometimes.
}
}
}
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

0 comments on commit ff72fc1

Please sign in to comment.