@twin.org/entity-storage-connector-file 0.0.3-next.9 → 0.9.0-next.1

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