@dxos/random-access-storage 0.4.3 → 0.4.4-main.b30fc36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,18 +13,12 @@ import { TimeSeriesCounter, trace } from '@dxos/tracing';
13
13
 
14
14
  import { Directory, type DiskInfo, type File, type Storage, StorageType, getFullPath } from '../common';
15
15
 
16
- /**
17
- * When handles weren't shared by different WebFS instances, browser tests
18
- * were sporadically failing with NonReadableError on test cases like:
19
- * webFs1.createFile->createWritable->write->close
20
- * webFs2.createFile->read
21
- * Presumably due to some handle-level write buffering.
22
- */
23
- const files = new Map<string, WebFile>();
24
16
  /**
25
17
  * Web file systems.
26
18
  */
27
19
  export class WebFS implements Storage {
20
+ private readonly _files = new Map<string, WebFile>();
21
+
28
22
  readonly type = StorageType.WEBFS;
29
23
 
30
24
  protected _root?: FileSystemDirectoryHandle;
@@ -32,13 +26,13 @@ export class WebFS implements Storage {
32
26
  constructor(public readonly path: string) {}
33
27
 
34
28
  public get size() {
35
- return files.size;
29
+ return this._files.size;
36
30
  }
37
31
 
38
32
  private _getFiles(path: string): Map<string, WebFile> {
39
33
  const fullName = this._getFullFilename(this.path, path);
40
34
  return new Map(
41
- [...files.entries()].filter(([path, file]) => {
35
+ [...this._files.entries()].filter(([path, file]) => {
42
36
  return path.includes(fullName) && !file.destroyed;
43
37
  }),
44
38
  );
@@ -88,19 +82,19 @@ export class WebFS implements Storage {
88
82
  getOrCreateFile: (...args) => this.getOrCreateFile(...args),
89
83
  remove: () => this._delete(sub),
90
84
  onFlush: async () => {
91
- await Promise.all(Array.from(this._getFiles(sub)).map(([path, file]) => file.flush()));
85
+ await Promise.all(Array.from(this._getFiles(sub)).map(([_, file]) => file.flush()));
92
86
  },
93
87
  });
94
88
  }
95
89
 
96
90
  getOrCreateFile(path: string, filename: string, opts?: any): File {
97
91
  const fullName = this._getFullFilename(path, filename);
98
- const existingFile = files.get(fullName);
92
+ const existingFile = this._files.get(fullName);
99
93
  if (existingFile) {
100
94
  return existingFile;
101
95
  }
102
96
  const file = this._createFile(fullName);
103
- files.set(fullName, file);
97
+ this._files.set(fullName, file);
104
98
  return file;
105
99
  }
106
100
 
@@ -109,7 +103,7 @@ export class WebFS implements Storage {
109
103
  fileName: fullName,
110
104
  file: this._initialize().then((root) => root.getFileHandle(fullName, { create: true })),
111
105
  destroy: async () => {
112
- files.delete(fullName);
106
+ this._files.delete(fullName);
113
107
  const root = await this._initialize();
114
108
  return root.removeEntry(fullName);
115
109
  },
@@ -120,7 +114,7 @@ export class WebFS implements Storage {
120
114
  await Promise.all(
121
115
  Array.from(this._getFiles(path)).map(async ([path, file]) => {
122
116
  await file.destroy().catch((err: any) => log.warn(err));
123
- files.delete(path);
117
+ this._files.delete(path);
124
118
  }),
125
119
  );
126
120
  }
@@ -128,22 +122,28 @@ export class WebFS implements Storage {
128
122
  async reset() {
129
123
  await this._initialize();
130
124
  for await (const filename of await (this._root as any).keys()) {
131
- files.delete(filename);
132
- await this._root!.removeEntry(filename, { recursive: true }).catch((err: any) => log.warn(err));
125
+ await this._root!.removeEntry(filename, { recursive: true }).catch((err: any) =>
126
+ log.warn('failed to remove an entry', { filename, err }),
127
+ );
128
+ this._files.delete(filename);
133
129
  }
134
130
  this._root = undefined;
135
131
  }
136
132
 
137
133
  async close() {
138
- await Promise.all(Array.from(files.values()).map((file) => file.close()));
134
+ await Promise.all(
135
+ Array.from(this._files.values()).map((file) => {
136
+ return file.close().catch((e) => log.warn('failed to close a file', { file: file.fileName, e }));
137
+ }),
138
+ );
139
139
  }
140
140
 
141
141
  private _getFullFilename(path: string, filename?: string) {
142
142
  // Replace slashes with underscores. Because we can't have slashes in filenames in Browser File Handle API.
143
143
  if (filename) {
144
- return getFullPath(path, filename).split('/').join('_');
144
+ return getFullPath(path, filename).replace(/\//g, '_');
145
145
  } else {
146
- return path.split('/').join('_');
146
+ return path.replace(/\//g, '_');
147
147
  }
148
148
  }
149
149
 
@@ -158,7 +158,7 @@ export class WebFS implements Storage {
158
158
  (async () => {
159
159
  switch (entry.kind) {
160
160
  case 'file':
161
- used += await (entry as FileSystemFileHandle).getFile().then((f) => (used += f.size));
161
+ used += await (entry as FileSystemFileHandle).getFile().then((f) => f.size);
162
162
  break;
163
163
  case 'directory':
164
164
  await recurse(entry as FileSystemDirectoryHandle);
@@ -182,7 +182,7 @@ export class WebFS implements Storage {
182
182
  // @trace.resource()
183
183
  export class WebFile extends EventEmitter implements File {
184
184
  @trace.info()
185
- private readonly _fileName: string;
185
+ readonly fileName: string;
186
186
 
187
187
  private readonly _fileHandle: Promise<FileSystemFileHandle>;
188
188
  private readonly _destroy: () => Promise<void>;
@@ -195,8 +195,12 @@ export class WebFile extends EventEmitter implements File {
195
195
  private _loadBufferPromise: Promise<void> | null = null;
196
196
 
197
197
  private _flushScheduled = false;
198
-
199
- private _flushPromise: Promise<void> | null = null;
198
+ private _flushPromise: Promise<void> = Promise.resolve();
199
+ /**
200
+ * Used to discard unnecessary scheduled flushes.
201
+ * If _flushNow() is called with a lower sequence number it should early exit.
202
+ */
203
+ private _flushSequence = 0;
200
204
 
201
205
  //
202
206
  // Metrics
@@ -235,7 +239,7 @@ export class WebFile extends EventEmitter implements File {
235
239
  destroy: () => Promise<void>;
236
240
  }) {
237
241
  super();
238
- this._fileName = fileName;
242
+ this.fileName = fileName;
239
243
  this._fileHandle = file;
240
244
  this._destroy = destroy;
241
245
 
@@ -284,10 +288,11 @@ export class WebFile extends EventEmitter implements File {
284
288
  }
285
289
 
286
290
  // Do not call directly, use _flushLater or _flushNow.
287
- private async _flushCache() {
288
- if (this.destroyed) {
291
+ private async _flushCache(sequence: number) {
292
+ if (this.destroyed || sequence < this._flushSequence) {
289
293
  return;
290
294
  }
295
+ this._flushSequence = sequence + 1;
291
296
 
292
297
  this._flushes.inc();
293
298
 
@@ -305,35 +310,25 @@ export class WebFile extends EventEmitter implements File {
305
310
  return;
306
311
  }
307
312
 
313
+ const sequence = this._flushSequence;
308
314
  setTimeout(async () => {
309
315
  // Making sure only one flush can run at a time.
310
- const promiseBefore = this._flushPromise;
311
316
  await this._flushPromise;
312
317
  this._flushScheduled = false;
313
-
314
- // _flushNow might have been called. In that case we don't want to run the flush again.
315
- if (promiseBefore !== this._flushPromise) {
316
- return;
317
- }
318
-
319
- this._flushPromise = this._flushCache().catch((err) => log.warn(err));
318
+ this._flushPromise = this._flushCache(sequence).catch((err) => log.warn(err));
320
319
  });
321
320
 
322
321
  this._flushScheduled = true;
323
322
  }
324
323
 
325
324
  private async _flushNow() {
326
- this._flushPromise = (this._flushPromise ?? Promise.resolve())
327
- .then(() => this._flushCache())
328
- .catch((err) => log.warn(err));
329
-
325
+ await this._flushPromise;
326
+ this._flushPromise = this._flushCache(this._flushSequence).catch((err) => log.warn(err));
330
327
  await this._flushPromise;
331
328
  }
332
329
 
333
330
  async read(offset: number, size: number) {
334
- if (this.destroyed) {
335
- throw new Error('Read of a destroyed file');
336
- }
331
+ this.assertNotDestroyed('Read');
337
332
 
338
333
  this._operations.inc();
339
334
  this._reads.inc();
@@ -353,6 +348,8 @@ export class WebFile extends EventEmitter implements File {
353
348
  }
354
349
 
355
350
  async write(offset: number, data: Buffer) {
351
+ this.assertNotDestroyed('Write');
352
+
356
353
  this._operations.inc();
357
354
  this._writes.inc();
358
355
  this._writeBytes.inc(data.length);
@@ -376,9 +373,11 @@ export class WebFile extends EventEmitter implements File {
376
373
  }
377
374
 
378
375
  async del(offset: number, size: number) {
376
+ this.assertNotDestroyed('Del');
377
+
379
378
  this._operations.inc();
380
379
 
381
- if (offset < 0 || size < 0) {
380
+ if (offset < 0 || size <= 0) {
382
381
  return;
383
382
  }
384
383
 
@@ -399,6 +398,8 @@ export class WebFile extends EventEmitter implements File {
399
398
  }
400
399
 
401
400
  async stat() {
401
+ this.assertNotDestroyed('Truncate');
402
+
402
403
  this._operations.inc();
403
404
 
404
405
  // NOTE: This will load all data from the file just to get it's size. While this is a lot of overhead, this works ok for out use cases.
@@ -413,6 +414,8 @@ export class WebFile extends EventEmitter implements File {
413
414
  }
414
415
 
415
416
  async truncate(offset: number) {
417
+ this.assertNotDestroyed('Truncate');
418
+
416
419
  this._operations.inc();
417
420
 
418
421
  if (!this._buffer) {
@@ -426,17 +429,32 @@ export class WebFile extends EventEmitter implements File {
426
429
  }
427
430
 
428
431
  async flush() {
432
+ this.assertNotDestroyed('Flush');
433
+
429
434
  await this._flushNow();
430
435
  }
431
436
 
437
+ /**
438
+ * It's best to avoid using this method as it doesn't really close a file.
439
+ * We could update the _opened flag and add a guard like for destroyed, but this would break
440
+ * the FileSystemFileHandle sharing required for browser tests to run, where writes are
441
+ * not immediately visible if using different file handles.
442
+ */
432
443
  async close(): Promise<void> {
433
444
  await this._flushNow();
434
445
  }
435
446
 
436
447
  @synchronized
437
448
  async destroy() {
438
- await this._flushNow();
439
- this.destroyed = true;
440
- return await this._destroy();
449
+ if (!this.destroyed) {
450
+ this.destroyed = true;
451
+ return await this._destroy();
452
+ }
453
+ }
454
+
455
+ private assertNotDestroyed(operation: string) {
456
+ if (this.destroyed) {
457
+ throw new Error(`${operation} on a destroyed or closed file`);
458
+ }
441
459
  }
442
460
  }
@@ -8,7 +8,7 @@ import { type Callback, type RandomAccessStorage } from 'random-access-storage';
8
8
  import { arrayToBuffer } from '@dxos/util';
9
9
 
10
10
  import { AbstractStorage } from './abstract-storage';
11
- import { StorageType } from './storage';
11
+ import { type DiskInfo, StorageType } from './storage';
12
12
 
13
13
  /**
14
14
  * Storage interface implementation for RAM.
@@ -42,4 +42,17 @@ export class MemoryStorage extends AbstractStorage {
42
42
 
43
43
  return file;
44
44
  }
45
+
46
+ async getDiskInfo(): Promise<DiskInfo> {
47
+ let used = 0;
48
+
49
+ for (const file of this._files.values()) {
50
+ const size = (file as any).length;
51
+ used += Number.isNaN(size) ? 0 : size;
52
+ }
53
+
54
+ return {
55
+ used,
56
+ };
57
+ }
45
58
  }
@@ -69,11 +69,11 @@ export class NodeStorage extends AbstractStorage implements Storage {
69
69
  const recurse = async (path: string) => {
70
70
  const pathStats = await stat(path);
71
71
 
72
- used += pathStats.size;
73
-
74
72
  if (pathStats.isDirectory()) {
75
73
  const entries = await readdir(path);
76
74
  await Promise.all(entries.map((entry) => recurse(join(path, entry))));
75
+ } else {
76
+ used += pathStats.size;
77
77
  }
78
78
  };
79
79
 
@@ -2,13 +2,14 @@
2
2
  // Copyright 2021 DXOS.org
3
3
  //
4
4
 
5
- import { expect, describe, test } from 'vitest';
5
+ import * as uuid from 'uuid';
6
+ import { describe, expect, test } from 'vitest';
6
7
 
7
8
  import { asyncTimeout } from '@dxos/async';
8
9
 
9
- import { StorageType, type File, type Storage } from '../common';
10
+ import { type File, type Storage, StorageType } from '../common';
10
11
 
11
- export const randomText = () => Math.random().toString(36).substring(2);
12
+ export const randomText = () => uuid.v4();
12
13
 
13
14
  export const storageTests = (testGroupName: StorageType, createStorage: () => Storage) => {
14
15
  const writeAndCheck = async (file: File, data: Buffer, offset = 0) => {
@@ -51,7 +52,7 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
51
52
  const directory = storage.createDirectory(directoryName);
52
53
 
53
54
  const count = 10;
54
- const files = [...Array(count)].map((name) => directory.getOrCreateFile(randomText()));
55
+ const files = [...Array(count)].map(() => directory.getOrCreateFile(randomText()));
55
56
 
56
57
  {
57
58
  // Create and check files amount.
@@ -173,7 +174,7 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
173
174
  const dir1 = storage.createDirectory('dir1');
174
175
  const dir2 = storage.createDirectory('dir2');
175
176
 
176
- const fileName = 'file';
177
+ const fileName = randomText();
177
178
  const buffer1 = Buffer.from(randomText());
178
179
  const buffer2 = Buffer.from(randomText());
179
180
 
@@ -195,7 +196,7 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
195
196
  const dir = storage.createDirectory('directory');
196
197
  const subDir = dir.createDirectory('subDirectory');
197
198
 
198
- const file = subDir.getOrCreateFile('file');
199
+ const file = subDir.getOrCreateFile(randomText());
199
200
  const buffer = Buffer.from(randomText());
200
201
  await file.write(0, buffer);
201
202
 
@@ -204,10 +205,82 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
204
205
  await file.close();
205
206
  });
206
207
 
208
+ test('all writes are flushed', async (t) => {
209
+ if (testGroupName === StorageType.RAM) {
210
+ t.skip();
211
+ }
212
+
213
+ const fileName = randomText();
214
+ {
215
+ const storage = createStorage();
216
+ const directory = storage.createDirectory();
217
+ const file = directory.getOrCreateFile(fileName);
218
+ await file.write(0, Buffer.alloc(10, '0'));
219
+ for (let i = 1; i <= 9; i++) {
220
+ void file.write(i, Buffer.from(String(i)));
221
+ }
222
+ await file.close();
223
+ }
224
+
225
+ {
226
+ const storage = createStorage();
227
+ const directory = storage.createDirectory();
228
+ const file = directory.getOrCreateFile(fileName);
229
+ const allContent = await file.read(0, (await file.stat()).size);
230
+ expect(allContent.toString()).toBe('0123456789');
231
+ }
232
+ });
233
+
234
+ test('flush interleaved with write', async (t) => {
235
+ if (testGroupName === StorageType.RAM) {
236
+ t.skip();
237
+ }
238
+
239
+ const fileName = randomText();
240
+ {
241
+ const storage = createStorage();
242
+ const directory = storage.createDirectory();
243
+ const file = directory.getOrCreateFile(fileName);
244
+ await file.write(0, Buffer.alloc(3, '0'));
245
+ await file.write(1, Buffer.from('1'));
246
+ void file.flush?.();
247
+ await file.write(2, Buffer.from('2'));
248
+ await file.close();
249
+ }
250
+
251
+ {
252
+ const storage = createStorage();
253
+ const directory = storage.createDirectory();
254
+ const file = directory.getOrCreateFile(fileName);
255
+ const allContent = await file.read(0, (await file.stat()).size);
256
+ expect(allContent.toString()).toBe('012');
257
+ }
258
+ });
259
+
260
+ test('operations on a destroyed file are rejected', async () => {
261
+ const storage = createStorage();
262
+ const directory = storage.createDirectory();
263
+ const file = directory.getOrCreateFile(randomText());
264
+
265
+ const buffer = Buffer.from(randomText());
266
+ await writeAndCheck(file, buffer);
267
+ await file.destroy();
268
+
269
+ expect(file.destroyed).toBeTruthy();
270
+ await expect(file.destroy()).resolves.toBeUndefined();
271
+ await expect(file.read(0, 1)).rejects.toThrow();
272
+ await expect(file.write(0, buffer)).rejects.toThrow();
273
+ await expect(file.del(0, 1)).rejects.toThrow();
274
+ await expect(file.stat()).rejects.toThrow();
275
+ if (file.truncate) {
276
+ await expect(file.truncate(0)).rejects.toThrow();
277
+ }
278
+ });
279
+
207
280
  test('delete directory', async () => {
208
281
  const storage = createStorage();
209
282
  const directory = storage.createDirectory();
210
- const file = directory.getOrCreateFile('file');
283
+ const file = directory.getOrCreateFile(randomText());
211
284
 
212
285
  const buffer = Buffer.from(randomText());
213
286
  await writeAndCheck(file, buffer);
@@ -364,5 +437,21 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
364
437
  const entries = await directory.list();
365
438
  expect(entries).toEqual(expect.arrayContaining(FILES));
366
439
  });
440
+
441
+ test('getDiskInfo returns correct size', async (t) => {
442
+ const storage = createStorage();
443
+ if (storage.type === StorageType.IDB) {
444
+ t.skip();
445
+ }
446
+ await storage.reset();
447
+ const directory = storage.createDirectory('dir');
448
+ const file = directory.getOrCreateFile(randomText());
449
+ const content = 'Hello, world!';
450
+ await file.write(0, Buffer.from(content));
451
+ await file.close();
452
+ await expect(storage.getDiskInfo?.() ?? Promise.resolve(-1)).resolves.toEqual({
453
+ used: content.length,
454
+ });
455
+ });
367
456
  });
368
457
  };