11import fs from "fs/promises" ;
22import path from "path" ;
33import lockfile from "proper-lockfile" ;
4- import type {
5- MetadataConfig ,
6- MetadataSchema ,
7- TranslationEntry ,
8- } from "../types" ;
9- import { getMetadataPath as getMetadataPathUtil } from "../utils/path-helpers" ;
10- import { withTimeout , DEFAULT_TIMEOUTS } from "../utils/timeout" ;
4+ import type { MetadataSchema , TranslationEntry } from "../types" ;
5+ import { DEFAULT_TIMEOUTS , withTimeout } from "../utils/timeout" ;
116
12- /**
13- * Default metadata schema
14- */
157export function createEmptyMetadata ( ) : MetadataSchema {
168 return {
179 version : "0.1" ,
@@ -23,190 +15,122 @@ export function createEmptyMetadata(): MetadataSchema {
2315 } ;
2416}
2517
26- // TODO (AleksandrSl 24/11/2025): Probably remove and use path util as is
27- /**
28- * Get the path to the metadata file
29- */
30- export function getMetadataPath (
31- config : MetadataConfig ,
32- filename ?: string ,
33- ) : string {
34- return getMetadataPathUtil ( config , filename ) ;
18+ export function loadMetadata ( path : string ) {
19+ return new MetadataManager ( path ) . loadMetadata ( ) ;
3520}
3621
37- /**
38- * Load metadata from disk
39- * Creates empty metadata if file doesn't exist
40- * Times out after 15 seconds to prevent indefinite hangs
41- */
42- export async function loadMetadata (
43- config : MetadataConfig ,
44- filename ?: string ,
45- ) : Promise < MetadataSchema > {
46- const metadataPath = getMetadataPath ( config , filename ) ;
47-
48- try {
49- const content = await withTimeout (
50- fs . readFile ( metadataPath , "utf-8" ) ,
51- DEFAULT_TIMEOUTS . METADATA ,
52- "Load metadata" ,
53- ) ;
54- return JSON . parse ( content ) as MetadataSchema ;
55- } catch ( error : any ) {
56- if ( error . code === "ENOENT" ) {
57- // File doesn't exist, create new metadata
58- return createEmptyMetadata ( ) ;
22+ export class MetadataManager {
23+ constructor ( private readonly filePath : string ) { }
24+
25+ /**
26+ * Load metadata from disk
27+ * Creates empty metadata if file doesn't exist
28+ * Times out after 15 seconds to prevent indefinite hangs
29+ */
30+ async loadMetadata ( ) : Promise < MetadataSchema > {
31+ try {
32+ const content = await withTimeout (
33+ fs . readFile ( this . filePath , "utf-8" ) ,
34+ DEFAULT_TIMEOUTS . METADATA ,
35+ "Load metadata" ,
36+ ) ;
37+ return JSON . parse ( content ) as MetadataSchema ;
38+ } catch ( error : any ) {
39+ if ( error . code === "ENOENT" ) {
40+ // File doesn't exist, create new metadata
41+ return createEmptyMetadata ( ) ;
42+ }
43+ throw error ;
5944 }
60- throw error ;
6145 }
62- }
63-
64- /**
65- * Save metadata to disk
66- * Times out after 15 seconds to prevent indefinite hangs
67- */
68- export async function saveMetadata (
69- config : MetadataConfig ,
70- metadata : MetadataSchema ,
71- filename ?: string ,
72- ) : Promise < void > {
73- const metadataPath = getMetadataPath ( config , filename ) ;
74- await withTimeout (
75- fs . mkdir ( path . dirname ( metadataPath ) , { recursive : true } ) ,
76- DEFAULT_TIMEOUTS . FILE_IO ,
77- "Create metadata directory" ,
78- ) ;
79-
80- metadata . stats = {
81- totalEntries : Object . keys ( metadata . entries ) . length ,
82- lastUpdated : new Date ( ) . toISOString ( ) ,
83- } ;
84-
85- await withTimeout (
86- fs . writeFile ( metadataPath , JSON . stringify ( metadata , null , 2 ) , "utf-8" ) ,
87- DEFAULT_TIMEOUTS . METADATA ,
88- "Save metadata" ,
89- ) ;
90- }
9146
92- /**
93- * Thread-safe save operation that atomically updates metadata with new entries
94- * Uses file locking to prevent concurrent write corruption
95- *
96- * @param config - Metadata configuration
97- * @param entries - Translation entries to add/update
98- * @param filename - Optional custom metadata filename
99- * @returns The updated metadata schema
100- */
101- export async function saveMetadataWithEntries (
102- config : MetadataConfig ,
103- entries : TranslationEntry [ ] ,
104- filename ?: string ,
105- ) : Promise < MetadataSchema > {
106- const metadataPath = getMetadataPath ( config , filename ) ;
107- const lockDir = path . dirname ( metadataPath ) ;
47+ /**
48+ * Save metadata to disk
49+ * Times out after 15 seconds to prevent indefinite hangs
50+ */
51+ private async saveMetadata ( metadata : MetadataSchema ) : Promise < void > {
52+ await withTimeout (
53+ fs . mkdir ( path . dirname ( this . filePath ) , { recursive : true } ) ,
54+ DEFAULT_TIMEOUTS . FILE_IO ,
55+ "Create metadata directory" ,
56+ ) ;
10857
109- // Ensure directory exists before locking
110- await fs . mkdir ( lockDir , { recursive : true } ) ;
58+ metadata . stats = {
59+ totalEntries : Object . keys ( metadata . entries ) . length ,
60+ lastUpdated : new Date ( ) . toISOString ( ) ,
61+ } ;
11162
112- // Create lock file if it doesn't exist (lockfile needs a file to lock)
113- try {
114- await fs . access ( metadataPath ) ;
115- } catch {
116- await fs . writeFile (
117- metadataPath ,
118- JSON . stringify ( createEmptyMetadata ( ) , null , 2 ) ,
119- "utf-8" ,
63+ await withTimeout (
64+ fs . writeFile ( this . filePath , JSON . stringify ( metadata , null , 2 ) , "utf-8" ) ,
65+ DEFAULT_TIMEOUTS . METADATA ,
66+ "Save metadata" ,
12067 ) ;
12168 }
12269
123- // Acquire lock with retry options
124- const release = await lockfile . lock ( metadataPath , {
125- retries : {
126- retries : 10 ,
127- minTimeout : 50 ,
128- maxTimeout : 1000 ,
129- } ,
130- stale : 2000 , // Consider lock stale after 5 seconds
131- } ) ;
132-
133- try {
134- // Re-load metadata inside lock to get latest state
135- const currentMetadata = await loadMetadata ( config , filename ) ;
136-
137- // Apply updates
138- const updatedMetadata = upsertEntries ( currentMetadata , entries ) ;
139-
140- // Save
141- await saveMetadata ( config , updatedMetadata , filename ) ;
70+ /**
71+ * Thread-safe save operation that atomically updates metadata with new entries
72+ * Uses file locking to prevent concurrent write corruption
73+ *
74+ * @param entries - Translation entries to add/update
75+ * @returns The updated metadata schema
76+ */
77+ async saveMetadataWithEntries (
78+ entries : TranslationEntry [ ] ,
79+ ) : Promise < MetadataSchema > {
80+ const lockDir = path . dirname ( this . filePath ) ;
81+
82+ // Ensure directory exists before locking
83+ await fs . mkdir ( lockDir , { recursive : true } ) ;
84+
85+ // Create lock file if it doesn't exist (lockfile needs a file to lock)
86+ try {
87+ await fs . access ( this . filePath ) ;
88+ } catch {
89+ // TODO (AleksandrSl 10/12/2025): Should I use another file as a lock?
90+ await fs . writeFile (
91+ this . filePath ,
92+ JSON . stringify ( createEmptyMetadata ( ) , null , 2 ) ,
93+ "utf-8" ,
94+ ) ;
95+ }
14296
143- return updatedMetadata ;
144- } finally {
145- // Always release lock
146- await release ( ) ;
97+ // Acquire lock with retry options
98+ const release = await lockfile . lock ( this . filePath , {
99+ retries : {
100+ retries : 10 ,
101+ minTimeout : 50 ,
102+ maxTimeout : 1000 ,
103+ } ,
104+ stale : 2000 , // Consider lock stale after 5 seconds
105+ } ) ;
106+
107+ try {
108+ // Re-load metadata inside lock to get latest state
109+ const currentMetadata = await this . loadMetadata ( ) ;
110+ for ( const entry of entries ) {
111+ currentMetadata . entries [ entry . hash ] = entry ;
112+ }
113+ await this . saveMetadata ( currentMetadata ) ;
114+ return currentMetadata ;
115+ } finally {
116+ await release ( ) ;
117+ }
147118 }
148- }
149-
150- /**
151- * Add or update a translation entry in metadata
152- */
153- export function upsertEntry (
154- metadata : MetadataSchema ,
155- entry : TranslationEntry ,
156- ) : MetadataSchema {
157- metadata . entries [ entry . hash ] = entry ;
158119
159- return metadata ;
160- }
161-
162- /**
163- * Batch add multiple entries
164- */
165- export function upsertEntries (
166- metadata : MetadataSchema ,
167- entries : TranslationEntry [ ] ,
168- ) : MetadataSchema {
169- let result = metadata ;
170- for ( const entry of entries ) {
171- result = upsertEntry ( result , entry ) ;
120+ /**
121+ * Get an entry by hash
122+ */
123+ getEntry (
124+ metadata : MetadataSchema ,
125+ hash : string ,
126+ ) : TranslationEntry | undefined {
127+ return metadata . entries [ hash ] ;
172128 }
173- return result ;
174- }
175-
176- /**
177- * Get an entry by hash
178- */
179- export function getEntry (
180- metadata : MetadataSchema ,
181- hash : string ,
182- ) : TranslationEntry | undefined {
183- return metadata . entries [ hash ] ;
184- }
185-
186- /**
187- * Check if an entry exists
188- */
189- export function hasEntry ( metadata : MetadataSchema , hash : string ) : boolean {
190- return hash in metadata . entries ;
191- }
192-
193- /**
194- * Remove entries by hash
195- */
196- export function removeEntries (
197- metadata : MetadataSchema ,
198- hashesToRemove : Set < string > ,
199- ) : MetadataSchema {
200- const filtered : Record < string , TranslationEntry > = { } ;
201129
202- for ( const [ hash , entry ] of Object . entries ( metadata . entries ) ) {
203- if ( ! hashesToRemove . has ( hash ) ) {
204- filtered [ hash ] = entry ;
205- }
130+ /**
131+ * Check if an entry exists
132+ */
133+ hasEntry ( metadata : MetadataSchema , hash : string ) : boolean {
134+ return hash in metadata . entries ;
206135 }
207-
208- return {
209- ...metadata ,
210- entries : filtered ,
211- } ;
212136}
0 commit comments