@@ -9,6 +9,8 @@ import { contentModuleToId } from './utils.js';
9
9
10
10
const SAVE_DEBOUNCE_MS = 500 ;
11
11
12
+ const MAX_DEPTH = 10 ;
13
+
12
14
/**
13
15
* Extends the DataStore with the ability to change entries and write them to disk.
14
16
* This is kept as a separate class to avoid needing node builtins at runtime, when read-only access is all that is needed.
@@ -86,7 +88,7 @@ export class MutableDataStore extends ImmutableDataStore {
86
88
87
89
if ( this . #assetImports. size === 0 ) {
88
90
try {
89
- await fs . writeFile ( filePath , 'export default new Map();' ) ;
91
+ await this . #writeFileAtomic ( filePath , 'export default new Map();' ) ;
90
92
} catch ( err ) {
91
93
throw new AstroError ( AstroErrorData . UnknownFilesystemError , { cause : err } ) ;
92
94
}
@@ -110,7 +112,7 @@ ${imports.join('\n')}
110
112
export default new Map([${ exports . join ( ', ' ) } ]);
111
113
` ;
112
114
try {
113
- await fs . writeFile ( filePath , code ) ;
115
+ await this . #writeFileAtomic ( filePath , code ) ;
114
116
} catch ( err ) {
115
117
throw new AstroError ( AstroErrorData . UnknownFilesystemError , { cause : err } ) ;
116
118
}
@@ -122,7 +124,7 @@ export default new Map([${exports.join(', ')}]);
122
124
123
125
if ( this . #moduleImports. size === 0 ) {
124
126
try {
125
- await fs . writeFile ( filePath , 'export default new Map();' ) ;
127
+ await this . #writeFileAtomic ( filePath , 'export default new Map();' ) ;
126
128
} catch ( err ) {
127
129
throw new AstroError ( AstroErrorData . UnknownFilesystemError , { cause : err } ) ;
128
130
}
@@ -143,7 +145,7 @@ export default new Map([${exports.join(', ')}]);
143
145
export default new Map([\n${ lines . join ( ',\n' ) } ]);
144
146
` ;
145
147
try {
146
- await fs . writeFile ( filePath , code ) ;
148
+ await this . #writeFileAtomic ( filePath , code ) ;
147
149
} catch ( err ) {
148
150
throw new AstroError ( AstroErrorData . UnknownFilesystemError , { cause : err } ) ;
149
151
}
@@ -190,6 +192,42 @@ export default new Map([\n${lines.join(',\n')}]);
190
192
}
191
193
}
192
194
195
+ #writing = new Set < string > ( ) ;
196
+ #pending = new Set < string > ( ) ;
197
+
198
+ async #writeFileAtomic( filePath : PathLike , data : string , depth = 0 ) {
199
+ if ( depth > MAX_DEPTH ) {
200
+ // If we hit the max depth, we skip a write to prevent the stack from growing too large
201
+ // In theory this means we may miss the latest data, but in practice this will only happen when the file is being written to very frequently
202
+ // so it will be saved on the next write. This is unlikely to ever happen in practice, as the writes are debounced. It requires lots of writes to very large files.
203
+ return ;
204
+ }
205
+ const fileKey = filePath . toString ( ) ;
206
+ // If we are already writing this file, instead of writing now, flag it as pending and write it when we're done.
207
+ if ( this . #writing. has ( fileKey ) ) {
208
+ this . #pending. add ( fileKey ) ;
209
+ return ;
210
+ }
211
+ // Prevent concurrent writes to this file by flagging it as being written
212
+ this . #writing. add ( fileKey ) ;
213
+
214
+ const tempFile = filePath instanceof URL ? new URL ( `${ filePath . href } .tmp` ) : `${ filePath } .tmp` ;
215
+ try {
216
+ // Write it to a temporary file first and then move it to prevent partial reads.
217
+ await fs . writeFile ( tempFile , data ) ;
218
+ await fs . rename ( tempFile , filePath ) ;
219
+ } finally {
220
+ // We're done writing. Unflag the file and check if there are any pending writes for this file.
221
+ this . #writing. delete ( fileKey ) ;
222
+ // If there are pending writes, we need to write again to ensure we flush the latest data.
223
+ if ( this . #pending. has ( fileKey ) ) {
224
+ this . #pending. delete ( fileKey ) ;
225
+ // Call ourself recursively to write the file again
226
+ await this . #writeFileAtomic( filePath , data , depth + 1 ) ;
227
+ }
228
+ }
229
+ }
230
+
193
231
scopedStore ( collectionName : string ) : DataStore {
194
232
return {
195
233
get : < TData extends Record < string , unknown > = Record < string , unknown > > ( key : string ) =>
@@ -298,7 +336,7 @@ export default new Map([\n${lines.join(',\n')}]);
298
336
return ;
299
337
}
300
338
try {
301
- await fs . writeFile ( filePath , this . toString ( ) ) ;
339
+ await this . #writeFileAtomic ( filePath , this . toString ( ) ) ;
302
340
this . #file = filePath ;
303
341
this . #dirty = false ;
304
342
} catch ( err ) {
0 commit comments