@dxos/random-access-storage 0.4.2 → 0.4.3-main.085f4d3

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,69 +310,55 @@ 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();
340
335
  this._readBytes.inc(size);
341
336
 
342
- if (!this._buffer) {
343
- await this._loadBufferGuarded();
344
- invariant(this._buffer);
345
- }
337
+ const buffer = await this.requireBuffer();
346
338
 
347
- if (offset + size > this._buffer.length) {
339
+ if (offset + size > buffer.length) {
348
340
  throw new Error('Read out of bounds');
349
341
  }
350
342
 
351
343
  // Copy data into a new buffer.
352
- return Buffer.from(this._buffer.slice(offset, offset + size));
344
+ return Buffer.from(buffer.slice(offset, offset + size));
353
345
  }
354
346
 
355
347
  async write(offset: number, data: Buffer) {
348
+ this.assertNotDestroyed('Write');
349
+
356
350
  this._operations.inc();
357
351
  this._writes.inc();
358
352
  this._writeBytes.inc(data.length);
359
353
 
360
- if (!this._buffer) {
361
- await this._loadBufferGuarded();
362
- invariant(this._buffer);
363
- }
354
+ const buffer = await this.requireBuffer();
364
355
 
365
- if (offset + data.length <= this._buffer.length) {
366
- this._buffer.set(data, offset);
356
+ if (offset + data.length <= buffer.length) {
357
+ buffer.set(data, offset);
367
358
  } else {
368
359
  // TODO(dmaretskyi): Optimize re-allocations.
369
360
  const newCache = new Uint8Array(offset + data.length);
370
- newCache.set(this._buffer);
361
+ newCache.set(buffer);
371
362
  newCache.set(data, offset);
372
363
  this._buffer = newCache;
373
364
  }
@@ -376,67 +367,86 @@ export class WebFile extends EventEmitter implements File {
376
367
  }
377
368
 
378
369
  async del(offset: number, size: number) {
370
+ this.assertNotDestroyed('Del');
371
+
379
372
  this._operations.inc();
380
373
 
381
- if (offset < 0 || size < 0) {
374
+ if (offset < 0 || size <= 0) {
382
375
  return;
383
376
  }
384
377
 
385
- if (!this._buffer) {
386
- await this._loadBufferGuarded();
387
- invariant(this._buffer);
388
- }
378
+ const buffer = await this.requireBuffer();
389
379
 
390
380
  let leftoverSize = 0;
391
- if (offset + size < this._buffer.length) {
392
- leftoverSize = this._buffer.length - (offset + size);
393
- this._buffer.set(this._buffer.slice(offset + size, offset + size + leftoverSize), offset);
381
+ if (offset + size < buffer.length) {
382
+ leftoverSize = buffer.length - (offset + size);
383
+ buffer.set(buffer.slice(offset + size, offset + size + leftoverSize), offset);
394
384
  }
395
385
 
396
- this._buffer = this._buffer.slice(0, offset + leftoverSize);
386
+ this._buffer = buffer.slice(0, offset + leftoverSize);
397
387
 
398
388
  this._flushLater();
399
389
  }
400
390
 
401
391
  async stat() {
392
+ this.assertNotDestroyed('Truncate');
393
+
402
394
  this._operations.inc();
403
395
 
404
396
  // 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.
405
- if (!this._buffer) {
406
- await this._loadBufferGuarded();
407
- invariant(this._buffer);
408
- }
397
+ const buffer = await this.requireBuffer();
409
398
 
410
399
  return {
411
- size: this._buffer.length,
400
+ size: buffer.length,
412
401
  };
413
402
  }
414
403
 
415
404
  async truncate(offset: number) {
416
- this._operations.inc();
405
+ this.assertNotDestroyed('Truncate');
417
406
 
418
- if (!this._buffer) {
419
- await this._loadBufferGuarded();
420
- invariant(this._buffer);
421
- }
407
+ this._operations.inc();
422
408
 
423
- this._buffer = this._buffer.slice(0, offset);
409
+ this._buffer = (await this.requireBuffer()).slice(0, offset);
424
410
 
425
411
  this._flushLater();
426
412
  }
427
413
 
428
414
  async flush() {
415
+ this.assertNotDestroyed('Flush');
416
+
429
417
  await this._flushNow();
430
418
  }
431
419
 
420
+ /**
421
+ * It's best to avoid using this method as it doesn't really close a file.
422
+ * We could update the _opened flag and add a guard like for destroyed, but this would break
423
+ * the FileSystemFileHandle sharing required for browser tests to run, where writes are
424
+ * not immediately visible if using different file handles.
425
+ */
432
426
  async close(): Promise<void> {
433
427
  await this._flushNow();
434
428
  }
435
429
 
436
430
  @synchronized
437
431
  async destroy() {
438
- await this._flushNow();
439
- this.destroyed = true;
440
- return await this._destroy();
432
+ if (!this.destroyed) {
433
+ this.destroyed = true;
434
+ await this._flushNow();
435
+ return await this._destroy();
436
+ }
437
+ }
438
+
439
+ private assertNotDestroyed(operation: string) {
440
+ if (this.destroyed) {
441
+ throw new Error(`${operation} on a destroyed or closed file`);
442
+ }
443
+ }
444
+
445
+ private async requireBuffer(): Promise<Uint8Array> {
446
+ if (!this._buffer) {
447
+ await this._loadBufferGuarded();
448
+ invariant(this._buffer);
449
+ }
450
+ return this._buffer;
441
451
  }
442
452
  }
@@ -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
  };