@dxos/random-access-storage 0.4.3-main.dda98d3 → 0.4.3-main.e5bbcda

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,12 +13,18 @@ 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>();
16
24
  /**
17
25
  * Web file systems.
18
26
  */
19
27
  export class WebFS implements Storage {
20
- private readonly _files = new Map<string, WebFile>();
21
-
22
28
  readonly type = StorageType.WEBFS;
23
29
 
24
30
  protected _root?: FileSystemDirectoryHandle;
@@ -26,13 +32,13 @@ export class WebFS implements Storage {
26
32
  constructor(public readonly path: string) {}
27
33
 
28
34
  public get size() {
29
- return this._files.size;
35
+ return files.size;
30
36
  }
31
37
 
32
38
  private _getFiles(path: string): Map<string, WebFile> {
33
39
  const fullName = this._getFullFilename(this.path, path);
34
40
  return new Map(
35
- [...this._files.entries()].filter(([path, file]) => {
41
+ [...files.entries()].filter(([path, file]) => {
36
42
  return path.includes(fullName) && !file.destroyed;
37
43
  }),
38
44
  );
@@ -82,19 +88,19 @@ export class WebFS implements Storage {
82
88
  getOrCreateFile: (...args) => this.getOrCreateFile(...args),
83
89
  remove: () => this._delete(sub),
84
90
  onFlush: async () => {
85
- await Promise.all(Array.from(this._getFiles(sub)).map(([_, file]) => file.flush()));
91
+ await Promise.all(Array.from(this._getFiles(sub)).map(([path, file]) => file.flush()));
86
92
  },
87
93
  });
88
94
  }
89
95
 
90
96
  getOrCreateFile(path: string, filename: string, opts?: any): File {
91
97
  const fullName = this._getFullFilename(path, filename);
92
- const existingFile = this._files.get(fullName);
98
+ const existingFile = files.get(fullName);
93
99
  if (existingFile) {
94
100
  return existingFile;
95
101
  }
96
102
  const file = this._createFile(fullName);
97
- this._files.set(fullName, file);
103
+ files.set(fullName, file);
98
104
  return file;
99
105
  }
100
106
 
@@ -103,7 +109,7 @@ export class WebFS implements Storage {
103
109
  fileName: fullName,
104
110
  file: this._initialize().then((root) => root.getFileHandle(fullName, { create: true })),
105
111
  destroy: async () => {
106
- this._files.delete(fullName);
112
+ files.delete(fullName);
107
113
  const root = await this._initialize();
108
114
  return root.removeEntry(fullName);
109
115
  },
@@ -114,7 +120,7 @@ export class WebFS implements Storage {
114
120
  await Promise.all(
115
121
  Array.from(this._getFiles(path)).map(async ([path, file]) => {
116
122
  await file.destroy().catch((err: any) => log.warn(err));
117
- this._files.delete(path);
123
+ files.delete(path);
118
124
  }),
119
125
  );
120
126
  }
@@ -122,28 +128,22 @@ export class WebFS implements Storage {
122
128
  async reset() {
123
129
  await this._initialize();
124
130
  for await (const filename of await (this._root as any).keys()) {
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);
131
+ files.delete(filename);
132
+ await this._root!.removeEntry(filename, { recursive: true }).catch((err: any) => log.warn(err));
129
133
  }
130
134
  this._root = undefined;
131
135
  }
132
136
 
133
137
  async 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
- );
138
+ await Promise.all(Array.from(files.values()).map((file) => file.close()));
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).replace(/\//g, '_');
144
+ return getFullPath(path, filename).split('/').join('_');
145
145
  } else {
146
- return path.replace(/\//g, '_');
146
+ return path.split('/').join('_');
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) => f.size);
161
+ used += await (entry as FileSystemFileHandle).getFile().then((f) => (used += 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
- readonly fileName: string;
185
+ private readonly _fileName: string;
186
186
 
187
187
  private readonly _fileHandle: Promise<FileSystemFileHandle>;
188
188
  private readonly _destroy: () => Promise<void>;
@@ -195,12 +195,8 @@ export class WebFile extends EventEmitter implements File {
195
195
  private _loadBufferPromise: Promise<void> | null = null;
196
196
 
197
197
  private _flushScheduled = false;
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;
198
+
199
+ private _flushPromise: Promise<void> | null = null;
204
200
 
205
201
  //
206
202
  // Metrics
@@ -239,7 +235,7 @@ export class WebFile extends EventEmitter implements File {
239
235
  destroy: () => Promise<void>;
240
236
  }) {
241
237
  super();
242
- this.fileName = fileName;
238
+ this._fileName = fileName;
243
239
  this._fileHandle = file;
244
240
  this._destroy = destroy;
245
241
 
@@ -288,11 +284,10 @@ export class WebFile extends EventEmitter implements File {
288
284
  }
289
285
 
290
286
  // Do not call directly, use _flushLater or _flushNow.
291
- private async _flushCache(sequence: number) {
292
- if (this.destroyed || sequence < this._flushSequence) {
287
+ private async _flushCache() {
288
+ if (this.destroyed) {
293
289
  return;
294
290
  }
295
- this._flushSequence = sequence + 1;
296
291
 
297
292
  this._flushes.inc();
298
293
 
@@ -310,55 +305,69 @@ export class WebFile extends EventEmitter implements File {
310
305
  return;
311
306
  }
312
307
 
313
- const sequence = this._flushSequence;
314
308
  setTimeout(async () => {
315
309
  // Making sure only one flush can run at a time.
310
+ const promiseBefore = this._flushPromise;
316
311
  await this._flushPromise;
317
312
  this._flushScheduled = false;
318
- this._flushPromise = this._flushCache(sequence).catch((err) => log.warn(err));
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));
319
320
  });
320
321
 
321
322
  this._flushScheduled = true;
322
323
  }
323
324
 
324
325
  private async _flushNow() {
325
- await this._flushPromise;
326
- this._flushPromise = this._flushCache(this._flushSequence).catch((err) => log.warn(err));
326
+ this._flushPromise = (this._flushPromise ?? Promise.resolve())
327
+ .then(() => this._flushCache())
328
+ .catch((err) => log.warn(err));
329
+
327
330
  await this._flushPromise;
328
331
  }
329
332
 
330
333
  async read(offset: number, size: number) {
331
- this.assertNotDestroyed('Read');
334
+ if (this.destroyed) {
335
+ throw new Error('Read of a destroyed file');
336
+ }
332
337
 
333
338
  this._operations.inc();
334
339
  this._reads.inc();
335
340
  this._readBytes.inc(size);
336
341
 
337
- const buffer = await this.requireBuffer();
342
+ if (!this._buffer) {
343
+ await this._loadBufferGuarded();
344
+ invariant(this._buffer);
345
+ }
338
346
 
339
- if (offset + size > buffer.length) {
347
+ if (offset + size > this._buffer.length) {
340
348
  throw new Error('Read out of bounds');
341
349
  }
342
350
 
343
351
  // Copy data into a new buffer.
344
- return Buffer.from(buffer.slice(offset, offset + size));
352
+ return Buffer.from(this._buffer.slice(offset, offset + size));
345
353
  }
346
354
 
347
355
  async write(offset: number, data: Buffer) {
348
- this.assertNotDestroyed('Write');
349
-
350
356
  this._operations.inc();
351
357
  this._writes.inc();
352
358
  this._writeBytes.inc(data.length);
353
359
 
354
- const buffer = await this.requireBuffer();
360
+ if (!this._buffer) {
361
+ await this._loadBufferGuarded();
362
+ invariant(this._buffer);
363
+ }
355
364
 
356
- if (offset + data.length <= buffer.length) {
357
- buffer.set(data, offset);
365
+ if (offset + data.length <= this._buffer.length) {
366
+ this._buffer.set(data, offset);
358
367
  } else {
359
368
  // TODO(dmaretskyi): Optimize re-allocations.
360
369
  const newCache = new Uint8Array(offset + data.length);
361
- newCache.set(buffer);
370
+ newCache.set(this._buffer);
362
371
  newCache.set(data, offset);
363
372
  this._buffer = newCache;
364
373
  }
@@ -367,86 +376,67 @@ export class WebFile extends EventEmitter implements File {
367
376
  }
368
377
 
369
378
  async del(offset: number, size: number) {
370
- this.assertNotDestroyed('Del');
371
-
372
379
  this._operations.inc();
373
380
 
374
- if (offset < 0 || size <= 0) {
381
+ if (offset < 0 || size < 0) {
375
382
  return;
376
383
  }
377
384
 
378
- const buffer = await this.requireBuffer();
385
+ if (!this._buffer) {
386
+ await this._loadBufferGuarded();
387
+ invariant(this._buffer);
388
+ }
379
389
 
380
390
  let leftoverSize = 0;
381
- if (offset + size < buffer.length) {
382
- leftoverSize = buffer.length - (offset + size);
383
- buffer.set(buffer.slice(offset + size, offset + size + leftoverSize), offset);
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);
384
394
  }
385
395
 
386
- this._buffer = buffer.slice(0, offset + leftoverSize);
396
+ this._buffer = this._buffer.slice(0, offset + leftoverSize);
387
397
 
388
398
  this._flushLater();
389
399
  }
390
400
 
391
401
  async stat() {
392
- this.assertNotDestroyed('Truncate');
393
-
394
402
  this._operations.inc();
395
403
 
396
404
  // 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.
397
- const buffer = await this.requireBuffer();
405
+ if (!this._buffer) {
406
+ await this._loadBufferGuarded();
407
+ invariant(this._buffer);
408
+ }
398
409
 
399
410
  return {
400
- size: buffer.length,
411
+ size: this._buffer.length,
401
412
  };
402
413
  }
403
414
 
404
415
  async truncate(offset: number) {
405
- this.assertNotDestroyed('Truncate');
406
-
407
416
  this._operations.inc();
408
417
 
409
- this._buffer = (await this.requireBuffer()).slice(0, offset);
418
+ if (!this._buffer) {
419
+ await this._loadBufferGuarded();
420
+ invariant(this._buffer);
421
+ }
422
+
423
+ this._buffer = this._buffer.slice(0, offset);
410
424
 
411
425
  this._flushLater();
412
426
  }
413
427
 
414
428
  async flush() {
415
- this.assertNotDestroyed('Flush');
416
-
417
429
  await this._flushNow();
418
430
  }
419
431
 
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
- */
426
432
  async close(): Promise<void> {
427
433
  await this._flushNow();
428
434
  }
429
435
 
430
436
  @synchronized
431
437
  async 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;
438
+ await this._flushNow();
439
+ this.destroyed = true;
440
+ return await this._destroy();
451
441
  }
452
442
  }
@@ -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 { type DiskInfo, StorageType } from './storage';
11
+ import { StorageType } from './storage';
12
12
 
13
13
  /**
14
14
  * Storage interface implementation for RAM.
@@ -42,17 +42,4 @@ 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
- }
58
45
  }
@@ -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
+
72
74
  if (pathStats.isDirectory()) {
73
75
  const entries = await readdir(path);
74
76
  await Promise.all(entries.map((entry) => recurse(join(path, entry))));
75
- } else {
76
- used += pathStats.size;
77
77
  }
78
78
  };
79
79
 
@@ -2,14 +2,13 @@
2
2
  // Copyright 2021 DXOS.org
3
3
  //
4
4
 
5
- import * as uuid from 'uuid';
6
- import { describe, expect, test } from 'vitest';
5
+ import { expect, describe, test } from 'vitest';
7
6
 
8
7
  import { asyncTimeout } from '@dxos/async';
9
8
 
10
- import { type File, type Storage, StorageType } from '../common';
9
+ import { StorageType, type File, type Storage } from '../common';
11
10
 
12
- export const randomText = () => uuid.v4();
11
+ export const randomText = () => Math.random().toString(36).substring(2);
13
12
 
14
13
  export const storageTests = (testGroupName: StorageType, createStorage: () => Storage) => {
15
14
  const writeAndCheck = async (file: File, data: Buffer, offset = 0) => {
@@ -52,7 +51,7 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
52
51
  const directory = storage.createDirectory(directoryName);
53
52
 
54
53
  const count = 10;
55
- const files = [...Array(count)].map(() => directory.getOrCreateFile(randomText()));
54
+ const files = [...Array(count)].map((name) => directory.getOrCreateFile(randomText()));
56
55
 
57
56
  {
58
57
  // Create and check files amount.
@@ -174,7 +173,7 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
174
173
  const dir1 = storage.createDirectory('dir1');
175
174
  const dir2 = storage.createDirectory('dir2');
176
175
 
177
- const fileName = randomText();
176
+ const fileName = 'file';
178
177
  const buffer1 = Buffer.from(randomText());
179
178
  const buffer2 = Buffer.from(randomText());
180
179
 
@@ -196,7 +195,7 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
196
195
  const dir = storage.createDirectory('directory');
197
196
  const subDir = dir.createDirectory('subDirectory');
198
197
 
199
- const file = subDir.getOrCreateFile(randomText());
198
+ const file = subDir.getOrCreateFile('file');
200
199
  const buffer = Buffer.from(randomText());
201
200
  await file.write(0, buffer);
202
201
 
@@ -205,82 +204,10 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
205
204
  await file.close();
206
205
  });
207
206
 
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
-
280
207
  test('delete directory', async () => {
281
208
  const storage = createStorage();
282
209
  const directory = storage.createDirectory();
283
- const file = directory.getOrCreateFile(randomText());
210
+ const file = directory.getOrCreateFile('file');
284
211
 
285
212
  const buffer = Buffer.from(randomText());
286
213
  await writeAndCheck(file, buffer);
@@ -437,21 +364,5 @@ export const storageTests = (testGroupName: StorageType, createStorage: () => St
437
364
  const entries = await directory.list();
438
365
  expect(entries).toEqual(expect.arrayContaining(FILES));
439
366
  });
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
- });
456
367
  });
457
368
  };