@twin.org/entity-storage-connector-file 0.0.3-next.3 → 0.0.3-next.30
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 +2 -2
- package/dist/es/fileEntityStorageConnector.js +429 -46
- 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 +15 -0
- package/docs/changelog.md +575 -45
- package/docs/examples.md +69 -1
- package/docs/reference/classes/FileEntityStorageConnector.md +294 -16
- package/docs/reference/interfaces/IFileEntityStorageConnectorConfig.md +28 -1
- package/docs/reference/interfaces/IFileEntityStorageConnectorConstructorOptions.md +4 -4
- package/locales/en.json +18 -2
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Entity Storage Connector File
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
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
|
-
|
|
252
|
+
return EntityStorageHelper.unPrepareEntity(item, [
|
|
253
|
+
FileEntityStorageConnector._PARTITION_KEY
|
|
254
|
+
]);
|
|
149
255
|
}
|
|
150
|
-
return
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
finalConditions
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
store[existingIndex] = finalEntity;
|
|
340
|
+
catch (err) {
|
|
341
|
+
throw new GeneralError(FileEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
|
|
176
342
|
}
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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 =
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|