@twin.org/entity-storage-connector-file 0.0.3-next.9 → 0.9.0
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/dist/es/fileEntityStorageConnector.js +424 -69
- package/dist/es/fileEntityStorageConnector.js.map +1 -1
- package/dist/es/models/IFileEntityStorageConnectorConfig.js.map +1 -1
- package/dist/types/fileEntityStorageConnector.d.ts +65 -2
- package/dist/types/models/IFileEntityStorageConnectorConfig.d.ts +14 -0
- package/docs/changelog.md +598 -53
- package/docs/reference/classes/FileEntityStorageConnector.md +286 -8
- package/docs/reference/interfaces/IFileEntityStorageConnectorConfig.md +26 -0
- package/locales/en.json +18 -2
- package/package.json +9 -9
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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 =
|
|
244
|
+
const item = store[index];
|
|
169
245
|
if (Is.objectValue(item)) {
|
|
170
|
-
|
|
246
|
+
return EntityStorageHelper.unPrepareEntity(item, [
|
|
247
|
+
FileEntityStorageConnector._PARTITION_KEY
|
|
248
|
+
]);
|
|
171
249
|
}
|
|
172
|
-
return
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
finalConditions
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
store[existingIndex] = finalEntity;
|
|
334
|
+
catch (err) {
|
|
335
|
+
throw new GeneralError(FileEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
|
|
198
336
|
}
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|