@twin.org/entity-storage-connector-file 0.0.3-next.3 → 0.0.3-next.31

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # TWIN Entity Storage Connector File
1
+ # Entity Storage Connector File
2
2
 
3
- Entity Storage connector implementation using file storage.
3
+ This package provides a file-backed backend for persisting entities on local or mounted disks in straightforward environments. It is designed to work with the wider storage ecosystem so applications can keep behaviour consistent across connectors and environments.
4
4
 
5
5
  ## Installation
6
6
 
@@ -1,10 +1,11 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { access, mkdir, readFile, rename, rm, statfs, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
6
- import { BaseError, Coerce, ComponentFactory, Guards, Is, ObjectHelper } from "@twin.org/core";
6
+ import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, Mutex, ObjectHelper, Validation } from "@twin.org/core";
7
7
  import { ComparisonOperator, EntityConditions, EntitySchemaFactory, EntitySchemaHelper, EntitySorter, LogicalOperator } from "@twin.org/entity";
8
+ import { EntityStorageHelper } from "@twin.org/entity-storage-models";
8
9
  /**
9
10
  * Class for performing entity storage operations in file.
10
11
  */
@@ -23,6 +24,26 @@ export class FileEntityStorageConnector {
23
24
  * @internal
24
25
  */
25
26
  static _PARTITION_KEY = "partitionId";
27
+ /**
28
+ * Default disk space warning threshold: 500 MB.
29
+ * @internal
30
+ */
31
+ static _DEFAULT_DISK_WARNING_THRESHOLD_BYTES = 500 * 1024 * 1024;
32
+ /**
33
+ * Default disk space error threshold: 100 MB.
34
+ * @internal
35
+ */
36
+ static _DEFAULT_DISK_ERROR_THRESHOLD_BYTES = 100 * 1024 * 1024;
37
+ /**
38
+ * Maximum time to wait for the store update lock.
39
+ * @internal
40
+ */
41
+ static _UPDATE_LOCK_TIMEOUT_MS = 60000;
42
+ /**
43
+ * The name for the schema.
44
+ * @internal
45
+ */
46
+ _entitySchemaName;
26
47
  /**
27
48
  * The schema for the entity.
28
49
  * @internal
@@ -43,6 +64,21 @@ export class FileEntityStorageConnector {
43
64
  * @internal
44
65
  */
45
66
  _directory;
67
+ /**
68
+ * Free bytes below which health reports an error.
69
+ * @internal
70
+ */
71
+ _diskErrorThresholdBytes;
72
+ /**
73
+ * Free bytes below which health reports a warning.
74
+ * @internal
75
+ */
76
+ _diskWarningThresholdBytes;
77
+ /**
78
+ * Milliseconds to wait for the directory lock before throwing.
79
+ * @internal
80
+ */
81
+ _lockTimeoutMs;
46
82
  /**
47
83
  * Create a new instance of FileEntityStorageConnector.
48
84
  * @param options The options for the connector.
@@ -52,10 +88,19 @@ export class FileEntityStorageConnector {
52
88
  Guards.stringValue(FileEntityStorageConnector.CLASS_NAME, "options.entitySchema", options.entitySchema);
53
89
  Guards.object(FileEntityStorageConnector.CLASS_NAME, "options.config", options.config);
54
90
  Guards.stringValue(FileEntityStorageConnector.CLASS_NAME, "options.config.directory", options.config.directory);
91
+ this._entitySchemaName = options.entitySchema;
55
92
  this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
56
93
  this._partitionContextIds = options.partitionContextIds;
57
94
  this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
58
95
  this._directory = path.resolve(options.config.directory);
96
+ this._diskErrorThresholdBytes =
97
+ options.config.diskErrorThresholdBytes ??
98
+ FileEntityStorageConnector._DEFAULT_DISK_ERROR_THRESHOLD_BYTES;
99
+ this._diskWarningThresholdBytes =
100
+ options.config.diskWarningThresholdBytes ??
101
+ FileEntityStorageConnector._DEFAULT_DISK_WARNING_THRESHOLD_BYTES;
102
+ this._lockTimeoutMs =
103
+ options.config.lockTimeoutMs ?? FileEntityStorageConnector._UPDATE_LOCK_TIMEOUT_MS;
59
104
  }
60
105
  /**
61
106
  * Bootstrap the connector by creating and initializing any resources it needs.
@@ -116,6 +161,65 @@ export class FileEntityStorageConnector {
116
161
  className() {
117
162
  return FileEntityStorageConnector.CLASS_NAME;
118
163
  }
164
+ /**
165
+ * Returns the health status of the component.
166
+ * @returns The health status of the component, can return multiple entries for elements within the component.
167
+ */
168
+ async health() {
169
+ try {
170
+ const stats = await statfs(this._directory);
171
+ const freeBytes = stats.bavail * stats.bsize;
172
+ if (freeBytes < this._diskErrorThresholdBytes) {
173
+ return [
174
+ {
175
+ source: FileEntityStorageConnector.CLASS_NAME,
176
+ status: HealthStatus.Error,
177
+ description: "healthDescription",
178
+ message: "diskSpaceError",
179
+ data: {
180
+ directory: this._directory,
181
+ freeBytes,
182
+ thresholdBytes: this._diskErrorThresholdBytes
183
+ }
184
+ }
185
+ ];
186
+ }
187
+ else if (freeBytes < this._diskWarningThresholdBytes) {
188
+ return [
189
+ {
190
+ source: FileEntityStorageConnector.CLASS_NAME,
191
+ status: HealthStatus.Warning,
192
+ description: "healthDescription",
193
+ message: "diskSpaceWarning",
194
+ data: {
195
+ directory: this._directory,
196
+ freeBytes,
197
+ thresholdBytes: this._diskWarningThresholdBytes
198
+ }
199
+ }
200
+ ];
201
+ }
202
+ return [
203
+ {
204
+ source: FileEntityStorageConnector.CLASS_NAME,
205
+ status: HealthStatus.Ok,
206
+ description: "healthDescription",
207
+ data: { directory: this._directory, freeBytes }
208
+ }
209
+ ];
210
+ }
211
+ catch {
212
+ return [
213
+ {
214
+ source: FileEntityStorageConnector.CLASS_NAME,
215
+ status: HealthStatus.Error,
216
+ description: "healthDescription",
217
+ message: "diskSpaceCheckFailed",
218
+ data: { directory: this._directory }
219
+ }
220
+ ];
221
+ }
222
+ }
119
223
  /**
120
224
  * Get the schema for the entities.
121
225
  * @returns The schema for the entities.
@@ -134,7 +238,7 @@ export class FileEntityStorageConnector {
134
238
  Guards.stringValue(FileEntityStorageConnector.CLASS_NAME, "id", id);
135
239
  const contextIds = await ContextIdStore.getContextIds();
136
240
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
137
- const store = await this.readStore();
241
+ const store = await this.readStoreWithLock();
138
242
  const finalConditions = conditions ?? [];
139
243
  if (Is.stringValue(partitionKey)) {
140
244
  finalConditions.push({
@@ -145,9 +249,11 @@ export class FileEntityStorageConnector {
145
249
  const index = this.findItem(store, id, secondaryIndex, finalConditions);
146
250
  const item = index >= 0 ? store[index] : undefined;
147
251
  if (Is.objectValue(item)) {
148
- ObjectHelper.propertyDelete(item, FileEntityStorageConnector._PARTITION_KEY);
252
+ return EntityStorageHelper.unPrepareEntity(item, [
253
+ FileEntityStorageConnector._PARTITION_KEY
254
+ ]);
149
255
  }
150
- return item;
256
+ return undefined;
151
257
  }
152
258
  /**
153
259
  * Set an entity.
@@ -159,25 +265,143 @@ export class FileEntityStorageConnector {
159
265
  Guards.object(FileEntityStorageConnector.CLASS_NAME, "entity", entity);
160
266
  const contextIds = await ContextIdStore.getContextIds();
161
267
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
162
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
163
- const store = await this.readStore();
164
- const finalEntity = ObjectHelper.clone(entity);
165
- const finalConditions = conditions ?? [];
166
- if (Is.stringValue(partitionKey)) {
167
- finalConditions.push({
168
- property: FileEntityStorageConnector._PARTITION_KEY,
169
- value: partitionKey
268
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, Is.stringValue(partitionKey)
269
+ ? [{ property: FileEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
270
+ : undefined, { nullBehavior: "omit" });
271
+ await this.withLock(async () => {
272
+ const store = await this.readStore();
273
+ const finalConditions = conditions ?? [];
274
+ if (Is.stringValue(partitionKey)) {
275
+ finalConditions.push({
276
+ property: FileEntityStorageConnector._PARTITION_KEY,
277
+ value: partitionKey
278
+ });
279
+ }
280
+ const existingIndex = this.findItem(store, prepared[this._primaryKey.property], undefined, finalConditions);
281
+ if (existingIndex >= 0) {
282
+ store[existingIndex] = prepared;
283
+ }
284
+ else {
285
+ store.push(prepared);
286
+ }
287
+ await this.writeStore(store);
288
+ });
289
+ }
290
+ /**
291
+ * Set multiple entities in a batch.
292
+ * @param entities The entities to set.
293
+ * @returns Nothing.
294
+ */
295
+ async setBatch(entities) {
296
+ Guards.arrayValue(FileEntityStorageConnector.CLASS_NAME, "entities", entities);
297
+ const contextIds = await ContextIdStore.getContextIds();
298
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
299
+ await this.withLock(async () => {
300
+ const store = await this.readStore();
301
+ for (const entity of entities) {
302
+ Guards.object(FileEntityStorageConnector.CLASS_NAME, "entity", entity);
303
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, Is.stringValue(partitionKey)
304
+ ? [{ property: FileEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
305
+ : undefined, { nullBehavior: "omit" });
306
+ const existingIndex = this.findItem(store, prepared[this._primaryKey.property], undefined, Is.stringValue(partitionKey)
307
+ ? [
308
+ {
309
+ property: FileEntityStorageConnector._PARTITION_KEY,
310
+ value: partitionKey
311
+ }
312
+ ]
313
+ : []);
314
+ if (existingIndex >= 0) {
315
+ store[existingIndex] = prepared;
316
+ }
317
+ else {
318
+ store.push(prepared);
319
+ }
320
+ }
321
+ await this.writeStore(store);
322
+ });
323
+ }
324
+ /**
325
+ * Remove all entities from the storage.
326
+ * @returns Nothing.
327
+ */
328
+ async empty() {
329
+ const contextIds = await ContextIdStore.getContextIds();
330
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
331
+ try {
332
+ await this.withLock(async () => {
333
+ const store = await this.readStore();
334
+ const remaining = Is.stringValue(partitionKey)
335
+ ? store.filter(item => ObjectHelper.propertyGet(item, FileEntityStorageConnector._PARTITION_KEY) !== partitionKey)
336
+ : [];
337
+ await this.writeStore(remaining);
170
338
  });
171
- ObjectHelper.propertySet(finalEntity, FileEntityStorageConnector._PARTITION_KEY, partitionKey);
172
339
  }
173
- const existingIndex = this.findItem(store, finalEntity[this._primaryKey.property], undefined, finalConditions);
174
- if (existingIndex >= 0) {
175
- store[existingIndex] = finalEntity;
340
+ catch (err) {
341
+ throw new GeneralError(FileEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
176
342
  }
177
- else {
178
- store.push(finalEntity);
343
+ }
344
+ /**
345
+ * Remove multiple entities by id.
346
+ * @param ids The ids of the entities to remove.
347
+ * @returns Nothing.
348
+ */
349
+ async removeBatch(ids) {
350
+ Guards.arrayValue(FileEntityStorageConnector.CLASS_NAME, "ids", ids);
351
+ const contextIds = await ContextIdStore.getContextIds();
352
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
353
+ try {
354
+ await this.withLock(async () => {
355
+ const store = await this.readStore();
356
+ const idSet = new Set(ids);
357
+ const remaining = store.filter(item => {
358
+ if (Is.stringValue(partitionKey) &&
359
+ ObjectHelper.propertyGet(item, FileEntityStorageConnector._PARTITION_KEY) !==
360
+ partitionKey) {
361
+ return true;
362
+ }
363
+ return !idSet.has(item[this._primaryKey.property]);
364
+ });
365
+ await this.writeStore(remaining);
366
+ });
367
+ }
368
+ catch (err) {
369
+ throw new GeneralError(FileEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
370
+ }
371
+ }
372
+ /**
373
+ * Teardown the storage by deleting the underlying store file.
374
+ * @param nodeLoggingComponentType The node logging component type.
375
+ * @returns True if the teardown process was successful.
376
+ */
377
+ async teardown(nodeLoggingComponentType) {
378
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
379
+ await nodeLogging?.log({
380
+ level: "info",
381
+ source: FileEntityStorageConnector.CLASS_NAME,
382
+ ts: Date.now(),
383
+ message: "storeTearingDown"
384
+ });
385
+ try {
386
+ await rm(this._directory, { recursive: true, force: true });
387
+ await nodeLogging?.log({
388
+ level: "info",
389
+ source: FileEntityStorageConnector.CLASS_NAME,
390
+ ts: Date.now(),
391
+ message: "storeTornDown"
392
+ });
393
+ return true;
394
+ }
395
+ catch (err) {
396
+ await nodeLogging?.log({
397
+ level: "error",
398
+ source: FileEntityStorageConnector.CLASS_NAME,
399
+ ts: Date.now(),
400
+ message: "teardownFailed",
401
+ error: BaseError.fromError(err)
402
+ });
403
+ return false;
179
404
  }
180
- await this.writeStore(store);
181
405
  }
182
406
  /**
183
407
  * Remove the entity.
@@ -189,19 +413,21 @@ export class FileEntityStorageConnector {
189
413
  Guards.stringValue(FileEntityStorageConnector.CLASS_NAME, "id", id);
190
414
  const contextIds = await ContextIdStore.getContextIds();
191
415
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
192
- const store = await this.readStore();
193
- const finalConditions = conditions ?? [];
194
- if (Is.stringValue(partitionKey)) {
195
- finalConditions.push({
196
- property: FileEntityStorageConnector._PARTITION_KEY,
197
- value: partitionKey
198
- });
199
- }
200
- const index = this.findItem(store, id, undefined, finalConditions);
201
- if (index >= 0) {
202
- store.splice(index, 1);
203
- await this.writeStore(store);
204
- }
416
+ await this.withLock(async () => {
417
+ const store = await this.readStore();
418
+ const finalConditions = conditions ?? [];
419
+ if (Is.stringValue(partitionKey)) {
420
+ finalConditions.push({
421
+ property: FileEntityStorageConnector._PARTITION_KEY,
422
+ value: partitionKey
423
+ });
424
+ }
425
+ const index = this.findItem(store, id, undefined, finalConditions);
426
+ if (index >= 0) {
427
+ store.splice(index, 1);
428
+ await this.writeStore(store);
429
+ }
430
+ });
205
431
  }
206
432
  /**
207
433
  * Find all the entities which match the conditions.
@@ -216,7 +442,14 @@ export class FileEntityStorageConnector {
216
442
  async query(conditions, sortProperties, properties, cursor, limit) {
217
443
  const contextIds = await ContextIdStore.getContextIds();
218
444
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
219
- let allEntities = await this.readStore();
445
+ EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
446
+ EntityStorageHelper.validateProperties(this._entitySchema, properties);
447
+ if (!Is.empty(limit)) {
448
+ const validationFailures = [];
449
+ Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
450
+ Validation.asValidationError(FileEntityStorageConnector.CLASS_NAME, "query", validationFailures);
451
+ }
452
+ let allEntities = await this.readStoreWithLock();
220
453
  const finalConditions = {
221
454
  conditions: [],
222
455
  logicalOperator: LogicalOperator.And
@@ -229,7 +462,7 @@ export class FileEntityStorageConnector {
229
462
  });
230
463
  }
231
464
  if (!Is.empty(conditions)) {
232
- finalConditions.conditions.push(conditions);
465
+ finalConditions.conditions.push(EntityStorageHelper.normalizeConditionValues(conditions));
233
466
  }
234
467
  const entities = [];
235
468
  const finalLimit = limit ?? FileEntityStorageConnector._DEFAULT_LIMIT;
@@ -241,9 +474,12 @@ export class FileEntityStorageConnector {
241
474
  for (let i = startIndex; i < allEntities.length; i++) {
242
475
  if (EntityConditions.check(allEntities[i], finalConditions) &&
243
476
  entities.length < finalLimit) {
244
- const entity = ObjectHelper.pick(allEntities[i], properties);
245
- ObjectHelper.propertyDelete(entity, FileEntityStorageConnector._PARTITION_KEY);
246
- entities.push(entity);
477
+ const entity = Is.arrayValue(properties)
478
+ ? ObjectHelper.pick(allEntities[i], properties)
479
+ : allEntities[i];
480
+ entities.push(EntityStorageHelper.unPrepareEntity(entity, [
481
+ FileEntityStorageConnector._PARTITION_KEY
482
+ ]));
247
483
  if (entities.length >= finalLimit) {
248
484
  if (i < allEntities.length - 1) {
249
485
  nextCursor = (i + 1).toString();
@@ -259,22 +495,144 @@ export class FileEntityStorageConnector {
259
495
  };
260
496
  }
261
497
  /**
262
- * Read the store from file.
498
+ * Count all the entities which match the conditions.
499
+ * @param conditions The optional conditions to match for the entities.
500
+ * @returns The total count of entities in the storage.
501
+ */
502
+ async count(conditions) {
503
+ const store = await this.readStoreWithLock();
504
+ const contextIds = await ContextIdStore.getContextIds();
505
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
506
+ const finalConditions = {
507
+ conditions: [],
508
+ logicalOperator: LogicalOperator.And
509
+ };
510
+ if (Is.stringValue(partitionKey)) {
511
+ finalConditions.conditions.push({
512
+ property: FileEntityStorageConnector._PARTITION_KEY,
513
+ comparison: ComparisonOperator.Equals,
514
+ value: partitionKey
515
+ });
516
+ }
517
+ if (!Is.empty(conditions)) {
518
+ finalConditions.conditions.push(EntityStorageHelper.normalizeConditionValues(conditions));
519
+ }
520
+ if (finalConditions.conditions.length === 0) {
521
+ return store.length;
522
+ }
523
+ return store.filter(item => EntityConditions.check(item, finalConditions)).length;
524
+ }
525
+ /**
526
+ * Get a unique list of all the context ids from the storage.
527
+ * @returns The list of unique context ids.
528
+ */
529
+ async getPartitionContextIds() {
530
+ const contextIds = {};
531
+ const store = await this.readStoreWithLock();
532
+ for (const entity of store) {
533
+ const partitionId = ObjectHelper.propertyGet(entity, FileEntityStorageConnector._PARTITION_KEY);
534
+ if (Is.stringValue(partitionId)) {
535
+ contextIds[partitionId] = ContextIdHelper.shortSplit(this._partitionContextIds ?? [], partitionId);
536
+ }
537
+ }
538
+ return Object.values(contextIds);
539
+ }
540
+ /**
541
+ * Create the target connector for performing the migration it will use a temporary storage location.
542
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
543
+ * @returns Connector for performing the migration.
544
+ */
545
+ async createTargetConnector(newEntitySchema) {
546
+ const baseName = path.basename(this._directory);
547
+ const parentDir = path.resolve(this._directory, "..");
548
+ const migrationDir = path.join(parentDir, `${baseName}_migration_${Date.now()}`);
549
+ return new FileEntityStorageConnector({
550
+ entitySchema: newEntitySchema,
551
+ partitionContextIds: this._partitionContextIds,
552
+ config: {
553
+ directory: migrationDir,
554
+ diskErrorThresholdBytes: this._diskErrorThresholdBytes,
555
+ diskWarningThresholdBytes: this._diskWarningThresholdBytes
556
+ }
557
+ });
558
+ }
559
+ /**
560
+ * Finalize the migration by tearing down the old connector and replacing it with the target connector.
561
+ * @param targetConnector The target connector to finalize the migration with.
562
+ * @param options The options to control how the migration is finalized.
563
+ * @param loggingComponentType The optional component type to use for logging the migration progress.
564
+ * @returns A promise that resolves when the migration is finalized.
565
+ */
566
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
567
+ const originalDir = this._directory;
568
+ const migrationDir = targetConnector._directory;
569
+ // Teardown the original connector, removing the entire source directory.
570
+ await this.teardown(loggingComponentType);
571
+ // Rename the migration directory into the original location.
572
+ await rename(migrationDir, originalDir);
573
+ return new FileEntityStorageConnector({
574
+ entitySchema: targetConnector._entitySchemaName,
575
+ partitionContextIds: targetConnector._partitionContextIds,
576
+ config: {
577
+ directory: this._directory,
578
+ diskErrorThresholdBytes: targetConnector._diskErrorThresholdBytes,
579
+ diskWarningThresholdBytes: targetConnector._diskWarningThresholdBytes
580
+ }
581
+ });
582
+ }
583
+ /**
584
+ * Cleanup the migration if a migration fails or needs to be aborted.
585
+ * @param targetConnector The target connector to cleanup the migration with.
586
+ * @param options The options to control how the migration is cleaned up.
587
+ * @param loggingComponentType The optional component type to use for logging the migration progress.
588
+ * @returns A promise that resolves when the migration is cleaned up.
589
+ */
590
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
591
+ await targetConnector?.teardown?.(loggingComponentType);
592
+ }
593
+ /**
594
+ * Read the store from file while holding the directory mutex.
595
+ * Use this for standalone reads (get, query, count, getPartitionContextIds) where
596
+ * no outer lock is held. Do not call from inside a withLock callback —
597
+ * use readStore instead to avoid a re-entrant deadlock.
598
+ * @returns The store.
599
+ * @internal
600
+ */
601
+ async readStoreWithLock() {
602
+ return this.withLock(async () => this.readStore());
603
+ }
604
+ /**
605
+ * Read the store from file without acquiring the directory mutex.
606
+ * Must only be called from inside a withLock callback, where the mutex
607
+ * is already held. Performing the read under the held lock prevents a race with
608
+ * the atomic rename(tmp→store.json) window on Windows that would otherwise
609
+ * return ENOENT and be misinterpreted as an empty store.
263
610
  * @returns The store.
264
611
  * @internal
265
612
  */
266
613
  async readStore() {
614
+ const filename = path.join(this._directory, "store.json");
615
+ let store;
616
+ try {
617
+ store = await readFile(filename, "utf8");
618
+ }
619
+ catch (err) {
620
+ if (ObjectHelper.propertyGet(err, "code") === "ENOENT") {
621
+ // The store has not been written yet, which is valid for a new store.
622
+ return [];
623
+ }
624
+ throw new GeneralError(FileEntityStorageConnector.CLASS_NAME, "readStoreFailed", { directory: this._directory }, err);
625
+ }
267
626
  try {
268
- const filename = path.join(this._directory, "store.json");
269
- const store = await readFile(filename, "utf8");
270
627
  return JSON.parse(store);
271
628
  }
272
- catch {
273
- return [];
629
+ catch (err) {
630
+ throw new GeneralError(FileEntityStorageConnector.CLASS_NAME, "readStoreCorrupt", { directory: this._directory }, err);
274
631
  }
275
632
  }
276
633
  /**
277
- * Write the store to the file.
634
+ * Write the store to the file, atomically replacing the previous version so
635
+ * a reader can never observe a partially written store.
278
636
  * @param store The store to write.
279
637
  * @returns Nothing.
280
638
  * @internal
@@ -282,9 +640,34 @@ export class FileEntityStorageConnector {
282
640
  async writeStore(store) {
283
641
  try {
284
642
  const filename = path.join(this._directory, "store.json");
285
- await writeFile(filename, JSON.stringify(store, undefined, "\t"), "utf8");
643
+ const tempFilename = `${filename}.tmp`;
644
+ await writeFile(tempFilename, JSON.stringify(store, undefined, "\t"), "utf8");
645
+ await rename(tempFilename, filename);
646
+ }
647
+ catch (err) {
648
+ throw new GeneralError(FileEntityStorageConnector.CLASS_NAME, "writeStoreFailed", { directory: this._directory }, err);
649
+ }
650
+ }
651
+ /**
652
+ * Serialize an update so that concurrent modifications cannot interleave
653
+ * their read-modify-write cycles, which would lose updates or tear the
654
+ * store file. The mutex is keyed on the storage directory, so every
655
+ * connector or worker using the same store serializes with the others.
656
+ * @param update The update operation to perform.
657
+ * @returns The result of the update.
658
+ * @internal
659
+ */
660
+ async withLock(update) {
661
+ await Mutex.lock(this._directory, {
662
+ timeoutMs: this._lockTimeoutMs,
663
+ throwOnTimeout: true
664
+ });
665
+ try {
666
+ return await update();
667
+ }
668
+ finally {
669
+ Mutex.unlock(this._directory);
286
670
  }
287
- catch { }
288
671
  }
289
672
  /**
290
673
  * Check if the dir exists.