@harperfast/rocksdb-js 1.2.0 → 1.4.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/README.md +33 -0
- package/bin/rocksdb-js.mjs +81 -19
- package/dist/index.cjs +55 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -4
- package/dist/index.d.mts +71 -4
- package/dist/index.mjs +55 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +14 -11
package/README.md
CHANGED
|
@@ -106,11 +106,26 @@ Sets global database settings.
|
|
|
106
106
|
Defaults to 32MB. Set to `0` (zero) disables block cache for future opened databases. Existing
|
|
107
107
|
block cache for any opened databases is resized immediately. Negative values throw an error.
|
|
108
108
|
- `compactOnClose: boolean` When `true`, compacts the database on close. Defaults to `false`.
|
|
109
|
+
- `writeBufferManagerAllowStall: boolean` When `true`, writes are stalled once the manager's
|
|
110
|
+
`buffer_size` is exceeded, providing a hard cap on memtable memory. When `false`, memtables are
|
|
111
|
+
allowed to grow past the limit and flushes are simply scheduled more aggressively. Off by
|
|
112
|
+
default to favor write throughput over hard memory bounding. Defaults to `false`.
|
|
113
|
+
- `writeBufferManagerCostToCache: boolean` When `true`, memtable memory is "charged" against the
|
|
114
|
+
shared block cache so the block cache and write buffers draw from a single pool. During write
|
|
115
|
+
bursts the cache shrinks to make room for memtables; once memtables flush, the cache can grow
|
|
116
|
+
back into the reclaimed space. Defaults to `false`.
|
|
117
|
+
- `writeBufferManagerSize: number` Total memtable memory limit (bytes) shared across every
|
|
118
|
+
database opened in this process. When set, RocksDB uses a single `WriteBufferManager` so write
|
|
119
|
+
buffers are bounded process-wide rather than per database. A value of `0` disables the manager.
|
|
120
|
+
Defaults to `0`.
|
|
109
121
|
|
|
110
122
|
```typescript
|
|
111
123
|
RocksDatabase.config({
|
|
112
124
|
blockCacheSize: 100 * 1024 * 1024, // 100MB
|
|
113
125
|
compactOnClose: true,
|
|
126
|
+
writeBufferManagerAllowStall: false,
|
|
127
|
+
writeBufferManagerCostToCache: false,
|
|
128
|
+
writeBufferManagerSize: 64 * 1024 * 1024, // 64MB
|
|
114
129
|
});
|
|
115
130
|
```
|
|
116
131
|
|
|
@@ -1421,6 +1436,24 @@ To run the tests without code coverage, run:
|
|
|
1421
1436
|
pnpm test
|
|
1422
1437
|
```
|
|
1423
1438
|
|
|
1439
|
+
#### Native C++ unit tests (GoogleTest)
|
|
1440
|
+
|
|
1441
|
+
Build and run standalone native tests (RocksDB + binding `core/` helpers, no Node in the test
|
|
1442
|
+
binary):
|
|
1443
|
+
|
|
1444
|
+
```bash
|
|
1445
|
+
pnpm test:native
|
|
1446
|
+
```
|
|
1447
|
+
|
|
1448
|
+
Coverage report for native tests (macOS/Linux; requires `lcov`):
|
|
1449
|
+
|
|
1450
|
+
```bash
|
|
1451
|
+
pnpm coverage:native
|
|
1452
|
+
# open coverage/native/html/index.html
|
|
1453
|
+
```
|
|
1454
|
+
|
|
1455
|
+
Note: Code coverage is not supported on Windows. Tests are run without coverage.
|
|
1456
|
+
|
|
1424
1457
|
To run a specific test suite, for example `"ranges"`, run:
|
|
1425
1458
|
|
|
1426
1459
|
```bash
|
package/bin/rocksdb-js.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { RocksDatabase, parseTransactionLog, versions } from '../dist/index.mjs';
|
|
4
4
|
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
-
import { mkdir, readdir } from 'node:fs/promises';
|
|
5
|
+
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
6
6
|
import { join, resolve } from 'node:path';
|
|
7
7
|
import { stdin, stdout } from 'node:process';
|
|
8
8
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -19,6 +19,7 @@ const COMMANDS = {
|
|
|
19
19
|
get: getCommand,
|
|
20
20
|
help: helpCommand,
|
|
21
21
|
log: logCommand,
|
|
22
|
+
logs: logCommand, // alias for log
|
|
22
23
|
prop: propCommand,
|
|
23
24
|
'purge-logs': purgeLogsCommand,
|
|
24
25
|
put: putCommand,
|
|
@@ -66,12 +67,12 @@ function completer(line) {
|
|
|
66
67
|
return [hits.length ? hits : columns, arg];
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
if (command === 'log' && currentDB) {
|
|
70
|
+
if ((command === 'log' || command === 'logs') && currentDB) {
|
|
70
71
|
if (parts.length === 2) {
|
|
71
72
|
const arg = parts[1];
|
|
72
73
|
const logs = currentDB.listLogs();
|
|
73
74
|
const hits = logs.filter((l) => l.startsWith(arg));
|
|
74
|
-
return [hits.length ? hits : logs, arg];
|
|
75
|
+
return [(hits.length ? hits : logs).map((s) => `${s} `), arg];
|
|
75
76
|
} else if (parts.length === 3) {
|
|
76
77
|
const [name, store] = parts.slice(1);
|
|
77
78
|
const logs = currentDB.listLogs();
|
|
@@ -81,7 +82,7 @@ function completer(line) {
|
|
|
81
82
|
const logFiles = readdirSync(logPath).filter(
|
|
82
83
|
(f) => (!store || f.startsWith(store)) && f.endsWith('.txnlog')
|
|
83
84
|
);
|
|
84
|
-
return [logFiles.length ? logFiles : [], store, name];
|
|
85
|
+
return [logFiles.length ? logFiles.map((f) => `${f} `) : [], store, name];
|
|
85
86
|
}
|
|
86
87
|
return [[], store, name];
|
|
87
88
|
}
|
|
@@ -149,6 +150,34 @@ function wrapColumns(columns) {
|
|
|
149
150
|
return prefix + lines.join('\n' + indent);
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
function formatHexDump(data) {
|
|
154
|
+
const bytesPerRow = 16;
|
|
155
|
+
const lines = [];
|
|
156
|
+
for (let offset = 0; offset < data.length; offset += bytesPerRow) {
|
|
157
|
+
const chunk = data.subarray(offset, offset + bytesPerRow);
|
|
158
|
+
const hexParts = [];
|
|
159
|
+
for (let i = 0; i < bytesPerRow; i++) {
|
|
160
|
+
if (i === 8) hexParts.push('');
|
|
161
|
+
hexParts.push(i < chunk.length ? chunk[i].toString(16).padStart(2, '0') : ' ');
|
|
162
|
+
}
|
|
163
|
+
let ascii = '';
|
|
164
|
+
for (const byte of chunk) {
|
|
165
|
+
ascii += byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : '.';
|
|
166
|
+
}
|
|
167
|
+
lines.push(
|
|
168
|
+
`${note(offset.toString(16).padStart(8, '0'))} ${hl(hexParts.join(' '))} ${note('|')}${hl(ascii)}${note('|')}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatBytes(bytes) {
|
|
175
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
176
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
|
177
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
178
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
async function run(fn) {
|
|
153
182
|
const start = Date.now();
|
|
154
183
|
const result = await fn();
|
|
@@ -320,40 +349,72 @@ async function logCommand(args) {
|
|
|
320
349
|
}
|
|
321
350
|
const { data } = log.entries[entry - 1];
|
|
322
351
|
console.log(`Entry ${entry} (${data.length.toLocaleString()} bytes)\n`);
|
|
323
|
-
console.log(
|
|
324
|
-
hl(
|
|
325
|
-
data
|
|
326
|
-
.toString('hex')
|
|
327
|
-
.match(/.{1,2}/g)
|
|
328
|
-
.join(' ')
|
|
329
|
-
)
|
|
330
|
-
);
|
|
352
|
+
console.log(formatHexDump(data));
|
|
331
353
|
console.log();
|
|
332
354
|
return;
|
|
333
355
|
}
|
|
334
356
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
console.log(`Entries = ${hl(log.entries.length)}`);
|
|
357
|
+
for (const anomaly of log.anomalies) {
|
|
358
|
+
console.log(bad(`! ${anomaly}`));
|
|
359
|
+
}
|
|
339
360
|
let i = 1;
|
|
340
361
|
for (const entry of log.entries) {
|
|
362
|
+
const ts =
|
|
363
|
+
Number.isFinite(entry.timestamp) && entry.timestamp > 0
|
|
364
|
+
? new Date(entry.timestamp).toISOString()
|
|
365
|
+
: String(entry.timestamp);
|
|
341
366
|
console.log(
|
|
342
|
-
`${String(i++).padStart(3)}) ${hl(
|
|
367
|
+
`${String(i++).padStart(3)}) ${hl(ts)} ${entry.length.toLocaleString()} bytes ${entry.flags & 0x01 ? note('(end of transaction)') : ''}`
|
|
343
368
|
);
|
|
369
|
+
if (entry.anomalies) {
|
|
370
|
+
for (const anomaly of entry.anomalies) {
|
|
371
|
+
console.log(` ${bad(`! ${anomaly}`)}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
344
374
|
}
|
|
345
375
|
console.log();
|
|
346
376
|
return;
|
|
347
377
|
}
|
|
378
|
+
|
|
348
379
|
const logPath = join(currentDB.path, 'transaction_logs', name);
|
|
349
380
|
const logFiles = await readdir(logPath);
|
|
350
381
|
for (const logFile of logFiles) {
|
|
351
382
|
if (!logFile.endsWith('.txnlog')) continue;
|
|
352
383
|
console.log(hl(logFile));
|
|
384
|
+
|
|
385
|
+
const log = parseTransactionLog(join(logPath, logFile), { skipData: true });
|
|
386
|
+
const ts =
|
|
387
|
+
Number.isFinite(log.timestamp) && log.timestamp > 0
|
|
388
|
+
? new Date(log.timestamp).toISOString()
|
|
389
|
+
: String(log.timestamp);
|
|
390
|
+
console.log(` Size = ${hl(`${log.size.toLocaleString()} bytes`)}`);
|
|
391
|
+
console.log(` Version = ${hl(log.version)}`);
|
|
392
|
+
console.log(` Timestamp = ${hl(ts)}`);
|
|
393
|
+
console.log(` Entries = ${hl(log.entries.length)}`);
|
|
394
|
+
const totalAnomalies = log.anomalies.length + log.entryAnomalyCount;
|
|
395
|
+
if (totalAnomalies > 0) {
|
|
396
|
+
console.log(` Anomalies = ${bad(totalAnomalies)}`);
|
|
397
|
+
for (const anomaly of log.anomalies) {
|
|
398
|
+
console.log(` ${bad(`! ${anomaly}`)}`);
|
|
399
|
+
}
|
|
400
|
+
if (log.entryAnomalyCount > 0) {
|
|
401
|
+
console.log(
|
|
402
|
+
` ${bad(`! ${log.entryAnomalyCount} entr${log.entryAnomalyCount === 1 ? 'y has' : 'ies have'} anomalies (run "log ${name} ${logFile}" for details)`)}`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
353
406
|
}
|
|
354
407
|
} else if (logs.length > 0) {
|
|
355
|
-
for (const
|
|
356
|
-
|
|
408
|
+
for (const name of logs) {
|
|
409
|
+
const files = (await readdir(join(currentDB.path, 'transaction_logs', name))).filter((f) =>
|
|
410
|
+
f.endsWith('.txnlog')
|
|
411
|
+
);
|
|
412
|
+
const maxlen = files.reduce((max, file) => Math.max(max, file.length), 0);
|
|
413
|
+
console.log(hl(name));
|
|
414
|
+
for (const file of files) {
|
|
415
|
+
const { size } = await stat(join(currentDB.path, 'transaction_logs', name, file));
|
|
416
|
+
console.log(` ${file.padEnd(maxlen)} ${note(`(${formatBytes(size)})`)}`);
|
|
417
|
+
}
|
|
357
418
|
}
|
|
358
419
|
} else {
|
|
359
420
|
console.log('No log stores found');
|
|
@@ -533,6 +594,7 @@ async function main() {
|
|
|
533
594
|
const command = line[0];
|
|
534
595
|
if (!command) continue;
|
|
535
596
|
const commandFn = COMMANDS[command];
|
|
597
|
+
console.log();
|
|
536
598
|
if (commandFn) {
|
|
537
599
|
try {
|
|
538
600
|
await commandFn(line.slice(1));
|
package/dist/index.cjs
CHANGED
|
@@ -658,6 +658,24 @@ var Store = class {
|
|
|
658
658
|
*/
|
|
659
659
|
maxKeySize;
|
|
660
660
|
/**
|
|
661
|
+
* The maximum number of memtables that can be queued per column family
|
|
662
|
+
* before writes stall. Higher values absorb write bursts while flushes catch
|
|
663
|
+
* up, at the cost of memory.
|
|
664
|
+
*/
|
|
665
|
+
maxWriteBufferNumber;
|
|
666
|
+
/**
|
|
667
|
+
* The bytes of recent memtable history to retain in memory for transaction
|
|
668
|
+
* conflict checking. `-1` derives the value from
|
|
669
|
+
* `maxWriteBufferNumber * writeBufferSize`.
|
|
670
|
+
*/
|
|
671
|
+
maxWriteBufferSizeToMaintain;
|
|
672
|
+
/**
|
|
673
|
+
* The total memtable budget in bytes across all column families. When the
|
|
674
|
+
* sum of memtables reaches this size, RocksDB flushes the largest one. `0`
|
|
675
|
+
* disables the global trigger so per-CF `writeBufferSize` drives flushing.
|
|
676
|
+
*/
|
|
677
|
+
dbWriteBufferSize;
|
|
678
|
+
/**
|
|
661
679
|
* The name of the store (e.g. the column family). Defaults to `'default'`.
|
|
662
680
|
*/
|
|
663
681
|
name;
|
|
@@ -667,7 +685,8 @@ var Store = class {
|
|
|
667
685
|
noBlockCache;
|
|
668
686
|
/**
|
|
669
687
|
* The number of threads to use for parallel operations. This is a RocksDB
|
|
670
|
-
* option.
|
|
688
|
+
* option. When undefined, the native layer picks
|
|
689
|
+
* `max(1, hardware_concurrency() / 2)`.
|
|
671
690
|
*/
|
|
672
691
|
parallelismThreads;
|
|
673
692
|
/**
|
|
@@ -725,6 +744,11 @@ var Store = class {
|
|
|
725
744
|
*/
|
|
726
745
|
transactionLogsPath;
|
|
727
746
|
/**
|
|
747
|
+
* The per-column-family memtable size in bytes at which the memtable is
|
|
748
|
+
* sealed and flushed.
|
|
749
|
+
*/
|
|
750
|
+
writeBufferSize;
|
|
751
|
+
/**
|
|
728
752
|
* The function used to encode keys using the shared `keyBuffer`.
|
|
729
753
|
*/
|
|
730
754
|
writeKey;
|
|
@@ -739,6 +763,7 @@ var Store = class {
|
|
|
739
763
|
if (options !== void 0 && options !== null && typeof options !== "object") throw new TypeError("Database options must be an object");
|
|
740
764
|
const { keyEncoding, readKey, writeKey } = initKeyEncoder(options?.keyEncoding, options?.keyEncoder);
|
|
741
765
|
this.db = new NativeDatabase();
|
|
766
|
+
this.dbWriteBufferSize = options?.dbWriteBufferSize;
|
|
742
767
|
this.decoder = options?.decoder ?? null;
|
|
743
768
|
this.disableWAL = options?.disableWAL ?? false;
|
|
744
769
|
this.enableStats = options?.enableStats ?? false;
|
|
@@ -749,9 +774,11 @@ var Store = class {
|
|
|
749
774
|
this.keyBuffer = KEY_BUFFER;
|
|
750
775
|
this.keyEncoding = keyEncoding;
|
|
751
776
|
this.maxKeySize = options?.maxKeySize ?? MAX_KEY_SIZE;
|
|
777
|
+
this.maxWriteBufferNumber = options?.maxWriteBufferNumber;
|
|
778
|
+
this.maxWriteBufferSizeToMaintain = options?.maxWriteBufferSizeToMaintain;
|
|
752
779
|
this.name = options?.name ?? "default";
|
|
753
780
|
this.noBlockCache = options?.noBlockCache;
|
|
754
|
-
this.parallelismThreads = options?.parallelismThreads
|
|
781
|
+
this.parallelismThreads = options?.parallelismThreads;
|
|
755
782
|
this.path = path;
|
|
756
783
|
this.pessimistic = options?.pessimistic ?? false;
|
|
757
784
|
this.readOnly = options?.readOnly ?? false;
|
|
@@ -763,6 +790,7 @@ var Store = class {
|
|
|
763
790
|
this.transactionLogMaxSize = options?.transactionLogMaxSize;
|
|
764
791
|
this.transactionLogRetention = options?.transactionLogRetention;
|
|
765
792
|
this.transactionLogsPath = options?.transactionLogsPath;
|
|
793
|
+
this.writeBufferSize = options?.writeBufferSize;
|
|
766
794
|
this.writeKey = writeKey;
|
|
767
795
|
}
|
|
768
796
|
/**
|
|
@@ -1024,8 +1052,11 @@ var Store = class {
|
|
|
1024
1052
|
open() {
|
|
1025
1053
|
if (this.db.opened) return true;
|
|
1026
1054
|
this.db.open(this.path, {
|
|
1055
|
+
dbWriteBufferSize: this.dbWriteBufferSize,
|
|
1027
1056
|
disableWAL: this.disableWAL,
|
|
1028
1057
|
enableStats: this.enableStats,
|
|
1058
|
+
maxWriteBufferNumber: this.maxWriteBufferNumber,
|
|
1059
|
+
maxWriteBufferSizeToMaintain: this.maxWriteBufferSizeToMaintain,
|
|
1029
1060
|
mode: this.pessimistic ? "pessimistic" : "optimistic",
|
|
1030
1061
|
name: this.name,
|
|
1031
1062
|
noBlockCache: this.noBlockCache,
|
|
@@ -1035,7 +1066,8 @@ var Store = class {
|
|
|
1035
1066
|
transactionLogMaxAgeThreshold: this.transactionLogMaxAgeThreshold,
|
|
1036
1067
|
transactionLogMaxSize: this.transactionLogMaxSize,
|
|
1037
1068
|
transactionLogRetentionMs: this.transactionLogRetention ? parseDuration(this.transactionLogRetention) : void 0,
|
|
1038
|
-
transactionLogsPath: this.transactionLogsPath
|
|
1069
|
+
transactionLogsPath: this.transactionLogsPath,
|
|
1070
|
+
writeBufferSize: this.writeBufferSize
|
|
1039
1071
|
});
|
|
1040
1072
|
return false;
|
|
1041
1073
|
}
|
|
@@ -1828,12 +1860,14 @@ var RocksDatabase = class RocksDatabase extends DBI {
|
|
|
1828
1860
|
//#endregion
|
|
1829
1861
|
//#region src/parse-transaction-log.ts
|
|
1830
1862
|
const { TRANSACTION_LOG_TOKEN } = constants;
|
|
1863
|
+
const MIN_VALID_TIMESTAMP = Date.UTC(2026, 0, 27);
|
|
1864
|
+
const VALID_FLAGS_MASK = 1;
|
|
1831
1865
|
/**
|
|
1832
1866
|
* Loads an entire transaction log file into memory.
|
|
1833
1867
|
* @param path - The path to the transaction log file.
|
|
1834
1868
|
* @returns The transaction log.
|
|
1835
1869
|
*/
|
|
1836
|
-
function parseTransactionLog(path) {
|
|
1870
|
+
function parseTransactionLog(path, options = {}) {
|
|
1837
1871
|
let stats;
|
|
1838
1872
|
try {
|
|
1839
1873
|
stats = (0, node_fs.statSync)(path);
|
|
@@ -1857,7 +1891,10 @@ function parseTransactionLog(path) {
|
|
|
1857
1891
|
const version = read(1).readUInt8(0);
|
|
1858
1892
|
if (version !== 1) throw new Error(`Unsupported transaction log file version: ${version}`);
|
|
1859
1893
|
const timestamp = read(8).readDoubleBE(0);
|
|
1894
|
+
const anomalies = [];
|
|
1895
|
+
if (!Number.isFinite(timestamp) || timestamp < MIN_VALID_TIMESTAMP) anomalies.push(`Header timestamp ${timestamp} predates 2026-01-27 (possible corruption)`);
|
|
1860
1896
|
const entries = [];
|
|
1897
|
+
let entryAnomalyCount = 0;
|
|
1861
1898
|
while (fileOffset < size) {
|
|
1862
1899
|
const timestamp = read(8).readDoubleBE(0);
|
|
1863
1900
|
if (timestamp === 0) {
|
|
@@ -1867,15 +1904,25 @@ function parseTransactionLog(path) {
|
|
|
1867
1904
|
const length = read(4).readUInt32BE(0);
|
|
1868
1905
|
const flags = read(1).readUInt8(0);
|
|
1869
1906
|
const data = read(length);
|
|
1870
|
-
|
|
1907
|
+
const entryAnomalies = [];
|
|
1908
|
+
if (!Number.isFinite(timestamp) || timestamp < MIN_VALID_TIMESTAMP) entryAnomalies.push(`timestamp ${timestamp} predates 2026-01-27 (possible corruption)`);
|
|
1909
|
+
if ((flags & ~VALID_FLAGS_MASK) !== 0) entryAnomalies.push(`flags 0x${flags.toString(16).padStart(2, "0")} contains undefined bits (expected 0x00 or 0x01)`);
|
|
1910
|
+
const entry = {
|
|
1871
1911
|
timestamp,
|
|
1872
1912
|
length,
|
|
1873
1913
|
flags,
|
|
1874
|
-
data
|
|
1875
|
-
}
|
|
1914
|
+
data: options.skipData ? void 0 : Buffer.from(data)
|
|
1915
|
+
};
|
|
1916
|
+
if (entryAnomalies.length > 0) {
|
|
1917
|
+
entry.anomalies = entryAnomalies;
|
|
1918
|
+
entryAnomalyCount += entryAnomalies.length;
|
|
1919
|
+
}
|
|
1920
|
+
entries.push(entry);
|
|
1876
1921
|
}
|
|
1877
1922
|
return {
|
|
1923
|
+
anomalies,
|
|
1878
1924
|
entries,
|
|
1925
|
+
entryAnomalyCount,
|
|
1879
1926
|
timestamp,
|
|
1880
1927
|
size,
|
|
1881
1928
|
version
|
|
@@ -2068,7 +2115,7 @@ function loadLastPosition(transactionLog, readUncommitted) {
|
|
|
2068
2115
|
//#region src/index.ts
|
|
2069
2116
|
const versions = {
|
|
2070
2117
|
rocksdb: version,
|
|
2071
|
-
"rocksdb-js": "1.
|
|
2118
|
+
"rocksdb-js": "1.4.0"
|
|
2072
2119
|
};
|
|
2073
2120
|
|
|
2074
2121
|
//#endregion
|