@nmakarov/cli-toolkit 0.16.0 → 0.18.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.
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/http-client2.ts
@@ -25,6 +35,9 @@ __export(http_client2_exports, {
25
35
  });
26
36
  module.exports = __toCommonJS(http_client2_exports);
27
37
 
38
+ // src/http-client2/index.ts
39
+ var import_path4 = __toESM(require("path"), 1);
40
+
28
41
  // src/errors.ts
29
42
  var FrameworkError = class extends Error {
30
43
  constructor(message) {
@@ -32,6 +45,12 @@ var FrameworkError = class extends Error {
32
45
  this.name = "FrameworkError";
33
46
  }
34
47
  };
48
+ var ParamError = class extends FrameworkError {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "ParamError";
52
+ }
53
+ };
35
54
  var HttpClientError = class extends FrameworkError {
36
55
  constructor(message, cause) {
37
56
  super(message);
@@ -39,6 +58,12 @@ var HttpClientError = class extends FrameworkError {
39
58
  this.name = "HttpClientError";
40
59
  }
41
60
  };
61
+ var FileDatabaseError = class extends FrameworkError {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = "FileDatabaseError";
65
+ }
66
+ };
42
67
 
43
68
  // src/http-client2/errors.ts
44
69
  function classifyError(error) {
@@ -140,6 +165,1201 @@ function shouldRetryError(classification) {
140
165
  return classification.retryable;
141
166
  }
142
167
 
168
+ // src/filedatabase/index.ts
169
+ var import_fs3 = __toESM(require("fs"), 1);
170
+ var import_path3 = __toESM(require("path"), 1);
171
+
172
+ // src/utils/os-utils.ts
173
+ var import_fs = __toESM(require("fs"), 1);
174
+ var import_path = __toESM(require("path"), 1);
175
+ var import_child_process = require("child_process");
176
+ function getFreeDiskSpace(targetPath) {
177
+ try {
178
+ let pathToCheck = targetPath;
179
+ if (!import_fs.default.existsSync(targetPath)) {
180
+ const parentDir = import_path.default.dirname(targetPath);
181
+ if (import_fs.default.existsSync(parentDir)) {
182
+ pathToCheck = parentDir;
183
+ } else {
184
+ pathToCheck = process.platform === "win32" ? "C:\\" : "/";
185
+ }
186
+ }
187
+ if (process.platform === "win32") {
188
+ return null;
189
+ } else {
190
+ const stdout = (0, import_child_process.execSync)(`df -k "${pathToCheck}"`, { encoding: "utf8" });
191
+ const lines = stdout.trim().split("\n");
192
+ const parts = lines[1].split(/\s+/);
193
+ const freeKb = parseInt(parts[3], 10);
194
+ return freeKb * 1024;
195
+ }
196
+ } catch (error) {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ // src/utils/fs-utils.ts
202
+ var import_fs2 = __toESM(require("fs"), 1);
203
+ var import_path2 = __toESM(require("path"), 1);
204
+ async function ensurePath(...pathParts) {
205
+ const fullPath = import_path2.default.resolve(...pathParts);
206
+ if (!import_fs2.default.existsSync(fullPath)) {
207
+ await import_fs2.default.promises.mkdir(fullPath, { recursive: true });
208
+ }
209
+ return fullPath;
210
+ }
211
+ function getFileExtension(dataType) {
212
+ switch (dataType) {
213
+ case "json-array":
214
+ case "json-object":
215
+ return "json";
216
+ case "text":
217
+ return "txt";
218
+ case "xml":
219
+ return "xml";
220
+ default:
221
+ return "json";
222
+ }
223
+ }
224
+
225
+ // src/utils/format-utils.ts
226
+ function bytesToHumanReadable(bytes) {
227
+ if (bytes === 0) return "0 B";
228
+ const k = 1024;
229
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
230
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
231
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
232
+ }
233
+
234
+ // src/utils/date-utils.ts
235
+ function isTimestampFolder(folderName) {
236
+ const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|\.\d{3}Z)$/;
237
+ if (!isoRegex.test(folderName)) {
238
+ return false;
239
+ }
240
+ const date = new Date(folderName);
241
+ return !isNaN(date.getTime()) && date.getTime() > 0;
242
+ }
243
+
244
+ // src/filedatabase/serializers.ts
245
+ function detectDataType(data) {
246
+ if (Array.isArray(data)) {
247
+ return "json-array";
248
+ } else if (typeof data === "object" && data !== null) {
249
+ return "json-object";
250
+ } else if (typeof data === "string") {
251
+ const trimmed = data.trim();
252
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
253
+ return "xml";
254
+ }
255
+ return "text";
256
+ } else {
257
+ return "text";
258
+ }
259
+ }
260
+ function serializeData(data) {
261
+ const dataType = detectDataType(data);
262
+ if (dataType === "json-array" || dataType === "json-object") {
263
+ return JSON.stringify(data, null, 4);
264
+ } else {
265
+ return String(data);
266
+ }
267
+ }
268
+ function deserializeData(rawData, dataType) {
269
+ if (dataType === "json-array" || dataType === "json-object") {
270
+ return JSON.parse(rawData);
271
+ } else {
272
+ return rawData;
273
+ }
274
+ }
275
+
276
+ // src/filedatabase/index.ts
277
+ var FileDatabase = class _FileDatabase {
278
+ basePath;
279
+ namespace;
280
+ tableName = null;
281
+ versioned;
282
+ maxVersions;
283
+ pageSize;
284
+ useMetadata;
285
+ freeSpaceThreshold;
286
+ logger;
287
+ // Current operation state
288
+ currentVersion = null;
289
+ currentVersionFolder = null;
290
+ currentFileNumber = 0;
291
+ currentRecord = 0;
292
+ hasReadFirstPage = false;
293
+ lastFileData = null;
294
+ metadata;
295
+ // Synopsis calculation functions
296
+ fileSynopsisFunction = null;
297
+ versionSynopsisFunction = null;
298
+ /**
299
+ * Constructor - accepts context as first parameter (new pattern)
300
+ * or config object (legacy pattern for backward compatibility)
301
+ */
302
+ constructor(contextOrConfig, options) {
303
+ let config;
304
+ if (contextOrConfig && typeof contextOrConfig === "object" && "params" in contextOrConfig) {
305
+ const context = contextOrConfig;
306
+ const opts = options || {};
307
+ const defs = {
308
+ basePath: "string default ./data",
309
+ namespace: "string default default",
310
+ tableName: "string",
311
+ maxVersions: "number default 5",
312
+ pageSize: "number default 5000"
313
+ };
314
+ const discovered = context.params.getAllForModule(defs);
315
+ config = { ...discovered, ...opts, logger: context.logger };
316
+ } else {
317
+ config = contextOrConfig;
318
+ }
319
+ if (!config.basePath) {
320
+ throw new ParamError("[FileDatabase] basePath is required");
321
+ }
322
+ this.basePath = config.basePath;
323
+ this.namespace = config.namespace || "default";
324
+ this.tableName = config.tableName || null;
325
+ this.versioned = config.versioned ?? true;
326
+ this.maxVersions = config.maxVersions || 5;
327
+ this.pageSize = config.pageSize || 5e3;
328
+ this.useMetadata = config.useMetadata !== false;
329
+ this.freeSpaceThreshold = config.freeSpaceThreshold || 100 * 1024 * 1024;
330
+ this.logger = config.logger || console;
331
+ this.metadata = this.getDefaultMetadata();
332
+ }
333
+ /**
334
+ * Initialize FileDatabase from context and options.
335
+ * Params are read via getAllForModule("filedatabase", defs) for --showUsedParams grouping.
336
+ */
337
+ static init(context, options) {
338
+ return new _FileDatabase(context, options ?? {});
339
+ }
340
+ /**
341
+ * Get default metadata structure
342
+ */
343
+ getDefaultMetadata() {
344
+ return {
345
+ version: this.currentVersion || null,
346
+ files: [],
347
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
348
+ modifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
349
+ totalRecords: 0,
350
+ synopsis: null,
351
+ dataType: null
352
+ };
353
+ }
354
+ /**
355
+ * Get the destination path (basePath/namespace/tableName[/version])
356
+ */
357
+ getDestinationPath(version) {
358
+ const errors = ["basePath", "namespace", "tableName"].filter((prop) => !this[prop]).map((prop) => `${prop} is not set`);
359
+ if (errors.length) {
360
+ throw new FileDatabaseError(`[FileDatabase] ${errors.join("; ")}`);
361
+ }
362
+ let parts = [this.basePath, this.namespace];
363
+ if (this.tableName) {
364
+ parts.push(...this.tableName.split("/"));
365
+ }
366
+ if (this.versioned && version) {
367
+ parts.push(version);
368
+ }
369
+ return import_path3.default.resolve(...parts);
370
+ }
371
+ /**
372
+ * Set current version and version folder
373
+ */
374
+ async setCurrentVersion(version) {
375
+ this.currentVersion = version;
376
+ this.currentVersionFolder = await ensurePath(this.getDestinationPath(), version);
377
+ }
378
+ /**
379
+ * Create a new version folder with comprehensive timestamp logic
380
+ * Only works in versioned mode
381
+ */
382
+ async makeNewVersion() {
383
+ if (!this.versioned) {
384
+ throw new FileDatabaseError("makeNewVersion() only works in versioned mode");
385
+ }
386
+ this.metadata = this.getDefaultMetadata();
387
+ const existingVersions = await this.getVersions();
388
+ let versionName;
389
+ if (existingVersions.length > 0) {
390
+ const maxTimestamp = existingVersions.reduce((max, version) => {
391
+ const versionDate = new Date(version.replace("Z", ""));
392
+ const maxDate2 = new Date(max.replace("Z", ""));
393
+ return versionDate > maxDate2 ? version : max;
394
+ });
395
+ const maxDate = new Date(maxTimestamp.replace("Z", ""));
396
+ const nextDate = new Date(maxDate.getTime() + 1e3);
397
+ versionName = nextDate.toISOString().split(".")[0] + "Z";
398
+ } else {
399
+ const now = /* @__PURE__ */ new Date();
400
+ versionName = now.toISOString().split(".")[0] + "Z";
401
+ }
402
+ await this.setCurrentVersion(versionName);
403
+ this.currentFileNumber = 0;
404
+ const versions = await this.getVersions();
405
+ while (versions.length > this.maxVersions) {
406
+ const versionToDelete = import_path3.default.resolve(this.getDestinationPath(), versions.shift());
407
+ this.logger.silly?.(`[FileDatabase] Deleting old version: ${versionToDelete}`);
408
+ await import_fs3.default.promises.rm(versionToDelete, { recursive: true, force: true });
409
+ }
410
+ return versionName;
411
+ }
412
+ /**
413
+ * Get list of all versions (sorted chronologically)
414
+ * Only works in versioned mode
415
+ */
416
+ async getVersions() {
417
+ if (!this.versioned) {
418
+ return [];
419
+ }
420
+ const destPath = this.getDestinationPath();
421
+ try {
422
+ await ensurePath(destPath);
423
+ const items = await import_fs3.default.promises.readdir(destPath);
424
+ const versions = items.filter((item) => {
425
+ const itemPath = import_path3.default.join(destPath, item);
426
+ const stat = import_fs3.default.statSync(itemPath);
427
+ return stat.isDirectory() && isTimestampFolder(item);
428
+ });
429
+ return versions.sort();
430
+ } catch (error) {
431
+ return [];
432
+ }
433
+ }
434
+ /**
435
+ * Get the latest version (most recent timestamp)
436
+ * Only works in versioned mode
437
+ * @returns Latest version string or null if no versions
438
+ */
439
+ async getLatestVersion() {
440
+ if (!this.versioned) {
441
+ throw new FileDatabaseError("getLatestVersion() only works in versioned mode");
442
+ }
443
+ const versions = await this.getVersions();
444
+ if (versions.length === 0) {
445
+ return null;
446
+ }
447
+ return versions[versions.length - 1];
448
+ }
449
+ /**
450
+ * Check if any data exists in this table
451
+ * Works for both versioned and non-versioned modes
452
+ * @returns true if data exists
453
+ */
454
+ async hasData() {
455
+ const tablePath = this.getDestinationPath();
456
+ if (!import_fs3.default.existsSync(tablePath)) {
457
+ return false;
458
+ }
459
+ if (this.versioned) {
460
+ const versions = await this.getVersions();
461
+ return versions.length > 0;
462
+ } else {
463
+ const items = await import_fs3.default.promises.readdir(tablePath);
464
+ return items.some(
465
+ (item) => item === "metadata.json" || item.match(/^\d{6}\.(json|txt|xml)$/) || item.endsWith(".json")
466
+ );
467
+ }
468
+ }
469
+ /**
470
+ * Auto-detect the data format in this table
471
+ * Used when reading existing data
472
+ * @returns Format detection result
473
+ */
474
+ async detectDataFormat() {
475
+ const tablePath = this.getDestinationPath();
476
+ if (!import_fs3.default.existsSync(tablePath)) {
477
+ return { versioned: false, hasMetadata: false, dataType: null };
478
+ }
479
+ const items = await import_fs3.default.promises.readdir(tablePath);
480
+ if (items.includes("metadata.json")) {
481
+ const metadata = JSON.parse(
482
+ await import_fs3.default.promises.readFile(import_path3.default.join(tablePath, "metadata.json"), "utf8")
483
+ );
484
+ return {
485
+ versioned: false,
486
+ hasMetadata: true,
487
+ dataType: metadata.dataType || null
488
+ };
489
+ }
490
+ const versionFolders = items.filter((item) => {
491
+ const itemPath = import_path3.default.join(tablePath, item);
492
+ const stat = import_fs3.default.statSync(itemPath);
493
+ return stat.isDirectory() && isTimestampFolder(item);
494
+ });
495
+ if (versionFolders.length > 0) {
496
+ const latestVersion = versionFolders.sort().pop();
497
+ const versionMetadataPath = import_path3.default.join(tablePath, latestVersion, "metadata.json");
498
+ return {
499
+ versioned: true,
500
+ hasMetadata: import_fs3.default.existsSync(versionMetadataPath),
501
+ dataType: null
502
+ };
503
+ }
504
+ const dataFiles = items.filter((f) => f.match(/^\d{6}\.(json|txt|xml)$/));
505
+ if (dataFiles.length > 0) {
506
+ return {
507
+ versioned: false,
508
+ hasMetadata: false,
509
+ dataType: null
510
+ };
511
+ }
512
+ return { versioned: false, hasMetadata: false, dataType: null };
513
+ }
514
+ /**
515
+ * Load metadata from JSON file
516
+ */
517
+ async loadMetadataJson(version) {
518
+ const metadataFile = import_path3.default.join(this.getDestinationPath(), version, "metadata.json");
519
+ if (import_fs3.default.existsSync(metadataFile)) {
520
+ try {
521
+ const rawData = await import_fs3.default.promises.readFile(metadataFile, "utf8");
522
+ return JSON.parse(rawData);
523
+ } catch (e) {
524
+ throw new FileDatabaseError(`Failed to read metadata for version "${version}": ${e.message}`);
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+ /**
530
+ * Build metadata by scanning files in a version folder (backward compatibility)
531
+ * Reads all files to get accurate counts - used when synopsis calculation is needed
532
+ */
533
+ async figureMetadataFromVersionFiles(version) {
534
+ const versionPath = import_path3.default.join(this.getDestinationPath(), version);
535
+ if (!import_fs3.default.existsSync(versionPath)) {
536
+ return this.getDefaultMetadata();
537
+ }
538
+ const files = (await import_fs3.default.promises.readdir(versionPath)).filter((file) => file !== "metadata.json" && !file.startsWith(".")).sort();
539
+ const metadata = this.getDefaultMetadata();
540
+ metadata.version = version;
541
+ metadata.files = [];
542
+ let totalRecords = 0;
543
+ let detectedDataType = null;
544
+ for (let i = 0; i < files.length; i++) {
545
+ const fileName = files[i];
546
+ const filePath = import_path3.default.join(versionPath, fileName);
547
+ try {
548
+ const rawData = await import_fs3.default.promises.readFile(filePath, "utf8");
549
+ const extension = import_path3.default.extname(fileName).toLowerCase();
550
+ let dataType = "text";
551
+ if (extension === ".json") {
552
+ dataType = "json-array";
553
+ } else if (extension === ".xml") {
554
+ dataType = "xml";
555
+ }
556
+ const fileData = deserializeData(rawData, dataType);
557
+ const recordsCount = Array.isArray(fileData) ? fileData.length : 1;
558
+ if (detectedDataType === null) {
559
+ detectedDataType = detectDataType(fileData);
560
+ }
561
+ const fileInfo = {
562
+ number: i + 1,
563
+ recordsCount,
564
+ fileName
565
+ };
566
+ metadata.files.push(fileInfo);
567
+ totalRecords += recordsCount;
568
+ } catch (error) {
569
+ this.logger.error?.(`[FileDatabase] Failed to read file ${fileName}: ${error.message}`);
570
+ }
571
+ }
572
+ metadata.totalRecords = totalRecords;
573
+ metadata.dataType = detectedDataType;
574
+ return metadata;
575
+ }
576
+ /**
577
+ * Build metadata optimized - only reads first and last files
578
+ * Assumes all middle files have the same record count as the first file
579
+ * Much faster for large datasets with many files
580
+ */
581
+ async buildMetadataOptimized(version) {
582
+ const versionPath = import_path3.default.join(this.getDestinationPath(), version);
583
+ if (!import_fs3.default.existsSync(versionPath)) {
584
+ return this.getDefaultMetadata();
585
+ }
586
+ const files = (await import_fs3.default.promises.readdir(versionPath)).filter((file) => file !== "metadata.json" && !file.startsWith(".")).sort();
587
+ if (files.length === 0) {
588
+ return this.getDefaultMetadata();
589
+ }
590
+ const metadata = this.getDefaultMetadata();
591
+ metadata.version = version;
592
+ metadata.files = files.map((fileName, index) => ({
593
+ number: index + 1,
594
+ recordsCount: 0,
595
+ fileName
596
+ }));
597
+ const firstFile = metadata.files[0];
598
+ const firstFilePath = import_path3.default.join(versionPath, firstFile.fileName);
599
+ const firstFileRaw = await import_fs3.default.promises.readFile(firstFilePath, "utf8");
600
+ let firstFileData;
601
+ try {
602
+ firstFileData = JSON.parse(firstFileRaw);
603
+ } catch (e) {
604
+ firstFileData = firstFileRaw;
605
+ }
606
+ metadata.dataType = detectDataType(firstFileData);
607
+ if (metadata.dataType === "json-array") {
608
+ const firstFileCount = Array.isArray(firstFileData) ? firstFileData.length : 1;
609
+ firstFile.recordsCount = firstFileCount;
610
+ for (let i = 1; i < metadata.files.length - 1; i++) {
611
+ metadata.files[i].recordsCount = firstFileCount;
612
+ }
613
+ if (files.length > 1) {
614
+ const lastFile = metadata.files[metadata.files.length - 1];
615
+ const lastFilePath = import_path3.default.join(versionPath, lastFile.fileName);
616
+ const lastFileRaw = await import_fs3.default.promises.readFile(lastFilePath, "utf8");
617
+ const lastFileData = deserializeData(lastFileRaw, metadata.dataType);
618
+ lastFile.recordsCount = Array.isArray(lastFileData) ? lastFileData.length : 1;
619
+ }
620
+ metadata.totalRecords = metadata.files.reduce((sum, file) => sum + file.recordsCount, 0);
621
+ } else {
622
+ metadata.files.forEach((file) => {
623
+ file.recordsCount = 1;
624
+ });
625
+ metadata.totalRecords = files.length;
626
+ }
627
+ return metadata;
628
+ }
629
+ /**
630
+ * Figure out metadata - tries JSON first, then builds from files
631
+ * Uses optimized building when no synopsis calculation is needed
632
+ */
633
+ async figureMetadata(version, useOptimized = true) {
634
+ if (this.useMetadata) {
635
+ const metadata = await this.loadMetadataJson(version);
636
+ if (metadata) {
637
+ return metadata;
638
+ }
639
+ }
640
+ if (useOptimized && !this.fileSynopsisFunction && !this.versionSynopsisFunction) {
641
+ return await this.buildMetadataOptimized(version);
642
+ }
643
+ return await this.figureMetadataFromVersionFiles(version);
644
+ }
645
+ /**
646
+ * Load version metadata (main entry point for loading)
647
+ */
648
+ async loadVersionMetadata(version) {
649
+ const metadata = await this.figureMetadata(version);
650
+ this.metadata = metadata;
651
+ return metadata;
652
+ }
653
+ /**
654
+ * Save version metadata to file
655
+ */
656
+ async saveVersionMetadata(metadata) {
657
+ if (!this.useMetadata) {
658
+ return;
659
+ }
660
+ const metadataToSave = metadata || this.metadata;
661
+ let metadataFile;
662
+ if (this.versioned) {
663
+ if (!this.currentVersion) {
664
+ return;
665
+ }
666
+ metadataFile = import_path3.default.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
667
+ } else {
668
+ metadataFile = import_path3.default.join(this.getDestinationPath(), "metadata.json");
669
+ }
670
+ await import_fs3.default.promises.writeFile(metadataFile, JSON.stringify(metadataToSave, null, 4), "utf8");
671
+ }
672
+ /**
673
+ * Create a new file entry in metadata
674
+ */
675
+ makeNewFile() {
676
+ this.currentFileNumber = (this.currentFileNumber || 0) + 1;
677
+ const dataType = this.metadata.dataType || "json-array";
678
+ const fileEntry = {
679
+ number: this.currentFileNumber,
680
+ recordsCount: 0,
681
+ fileName: `${this.currentFileNumber.toString().padStart(6, "0")}.${getFileExtension(dataType)}`
682
+ };
683
+ this.metadata.files.push(fileEntry);
684
+ this.lastFileData = null;
685
+ this.logger.silly?.(`[FileDatabase] Created new file: ${fileEntry.fileName}, fileNumber: ${this.currentFileNumber}`);
686
+ }
687
+ /**
688
+ * Figure out what data to write and which file to use (for pagination)
689
+ * @param data - Data to write
690
+ * @param targetFileIndex - Optional index of existing file to overwrite (when customMetadata matches)
691
+ * @param forceNewFile - If true, always create a new file (when customMetadata provided but no match)
692
+ */
693
+ figureOutDataAndFileToWrite(data, targetFileIndex = null, forceNewFile = false) {
694
+ let dataToWrite;
695
+ let dataLeftOver;
696
+ const incomingDataType = detectDataType(data);
697
+ if (this.metadata.dataType !== incomingDataType) {
698
+ this.metadata.dataType = incomingDataType;
699
+ }
700
+ if (targetFileIndex !== null && targetFileIndex < this.metadata.files.length) {
701
+ const targetFile = this.metadata.files[targetFileIndex];
702
+ if (!Array.isArray(data)) {
703
+ dataToWrite = data;
704
+ dataLeftOver = null;
705
+ return { dataToWrite, dataLeftOver, fileName: targetFile.fileName };
706
+ } else {
707
+ dataToWrite = data.slice(0, this.pageSize);
708
+ dataLeftOver = data.slice(this.pageSize);
709
+ this.lastFileData = dataToWrite;
710
+ return { dataToWrite, dataLeftOver, fileName: targetFile.fileName };
711
+ }
712
+ }
713
+ let newlyCreatedFileIndex = null;
714
+ if (forceNewFile) {
715
+ const filesBeforeCreate = this.metadata.files.length;
716
+ this.makeNewFile();
717
+ newlyCreatedFileIndex = filesBeforeCreate;
718
+ this.logger.silly?.(`[FileDatabase] Creating new file for unique custom metadata combination, fileNumber: ${this.currentFileNumber}`);
719
+ } else if (this.metadata.files.length === 0) {
720
+ this.makeNewFile();
721
+ }
722
+ const lastFile = this.metadata.files[this.metadata.files.length - 1];
723
+ const lastFileRecordsCount = lastFile.recordsCount;
724
+ if (forceNewFile && newlyCreatedFileIndex !== null) {
725
+ const newlyCreatedFile = this.metadata.files[newlyCreatedFileIndex];
726
+ if (newlyCreatedFile && newlyCreatedFile.fileName !== lastFile.fileName) {
727
+ this.logger.warn?.(`[FileDatabase] Warning: Newly created file ${newlyCreatedFile.fileName} doesn't match last file ${lastFile.fileName}`);
728
+ }
729
+ }
730
+ if (!Array.isArray(data) && !forceNewFile) {
731
+ const lastFileExtension = import_path3.default.extname(lastFile.fileName);
732
+ const expectedExtension = `.${getFileExtension(incomingDataType)}`;
733
+ if (lastFileExtension !== expectedExtension) {
734
+ if (lastFileRecordsCount > 0) {
735
+ this.makeNewFile();
736
+ } else {
737
+ lastFile.fileName = `${this.currentFileNumber.toString().padStart(6, "0")}.${getFileExtension(incomingDataType)}`;
738
+ }
739
+ }
740
+ } else if (!Array.isArray(data) && forceNewFile) {
741
+ const lastFileExtension = import_path3.default.extname(lastFile.fileName);
742
+ const expectedExtension = `.${getFileExtension(incomingDataType)}`;
743
+ if (lastFileExtension !== expectedExtension) {
744
+ lastFile.fileName = `${this.currentFileNumber.toString().padStart(6, "0")}.${getFileExtension(incomingDataType)}`;
745
+ }
746
+ }
747
+ if (Array.isArray(data)) {
748
+ if (forceNewFile) {
749
+ dataToWrite = data.slice(0, this.pageSize);
750
+ dataLeftOver = data.slice(this.pageSize);
751
+ this.lastFileData = dataToWrite;
752
+ } else if (lastFileRecordsCount < this.pageSize) {
753
+ dataToWrite = [...this.lastFileData || [], ...data.slice(0, this.pageSize - lastFileRecordsCount)];
754
+ dataLeftOver = data.slice(this.pageSize - lastFileRecordsCount);
755
+ this.lastFileData = dataToWrite;
756
+ } else {
757
+ this.makeNewFile();
758
+ dataToWrite = data.slice(0, this.pageSize);
759
+ dataLeftOver = data.slice(this.pageSize);
760
+ this.lastFileData = dataToWrite;
761
+ }
762
+ } else {
763
+ dataToWrite = data;
764
+ dataLeftOver = null;
765
+ }
766
+ const fileName = this.metadata.files[this.metadata.files.length - 1].fileName;
767
+ this.logger.silly?.(
768
+ `[FileDatabase] figureOutDataAndFileToWrite: filename=${fileName}, forceNewFile=${forceNewFile}, targetFileIndex=${targetFileIndex}, dataToWrite.length=${Array.isArray(dataToWrite) ? dataToWrite.length : "N/A"}, lastFileRecordsCount=${lastFileRecordsCount}`
769
+ );
770
+ return { dataToWrite, dataLeftOver, fileName };
771
+ }
772
+ /**
773
+ * Calculate file-level synopsis if function is set
774
+ */
775
+ calculateFileSynopsis(data, fileIndex = this.metadata.files.length - 1) {
776
+ if (!this.fileSynopsisFunction) {
777
+ return;
778
+ }
779
+ const fileInfo = this.metadata.files[fileIndex];
780
+ const enhancedFileInfo = this.fileSynopsisFunction(fileInfo, data);
781
+ this.metadata.files[fileIndex] = enhancedFileInfo;
782
+ }
783
+ /**
784
+ * Calculate version-level synopsis if function is set
785
+ */
786
+ calculateVersionSynopsis() {
787
+ if (!this.versionSynopsisFunction) {
788
+ return;
789
+ }
790
+ const enhancedMetadata = this.versionSynopsisFunction(this.metadata);
791
+ this.metadata = enhancedMetadata;
792
+ }
793
+ /**
794
+ * Update metadata after writing data
795
+ */
796
+ updateMetadata(dataToWrite, fileName, customMetadata) {
797
+ let currentFile;
798
+ if (fileName) {
799
+ const foundFile = this.metadata.files.find((file) => file.fileName === fileName);
800
+ if (!foundFile) {
801
+ this.logger.warn?.(`[FileDatabase] File ${fileName} not found in metadata, using last file`);
802
+ currentFile = this.metadata.files[this.metadata.files.length - 1];
803
+ } else {
804
+ currentFile = foundFile;
805
+ }
806
+ } else {
807
+ currentFile = this.metadata.files[this.metadata.files.length - 1];
808
+ }
809
+ const recordsCount = Array.isArray(dataToWrite) ? dataToWrite.length : 1;
810
+ currentFile.recordsCount = recordsCount;
811
+ if (customMetadata) {
812
+ Object.assign(currentFile, customMetadata);
813
+ }
814
+ const fileIndex = this.metadata.files.indexOf(currentFile);
815
+ if (fileIndex !== -1) {
816
+ this.calculateFileSynopsis(dataToWrite, fileIndex);
817
+ }
818
+ this.metadata.version = this.currentVersion;
819
+ this.metadata.modifiedAt = (/* @__PURE__ */ new Date()).toISOString();
820
+ this.metadata.dataType = detectDataType(dataToWrite);
821
+ this.metadata.totalRecords = this.metadata.files.reduce((sum, file) => sum + (file.recordsCount || 0), 0);
822
+ this.logger.silly?.(
823
+ `[FileDatabase] Updated metadata for file ${currentFile.fileName}: recordsCount=${recordsCount}, totalRecords=${this.metadata.totalRecords}`
824
+ );
825
+ }
826
+ /**
827
+ * Safe write with disk space check
828
+ */
829
+ async safeWrite(filePath, data) {
830
+ const serializedData = serializeData(data);
831
+ const dir = import_path3.default.dirname(filePath);
832
+ const requiredBytes = Buffer.byteLength(serializedData, "utf8");
833
+ const freeBytes = getFreeDiskSpace(dir);
834
+ if (freeBytes !== null) {
835
+ if (freeBytes < requiredBytes) {
836
+ throw new FileDatabaseError(
837
+ `Not enough disk space. Required: ${bytesToHumanReadable(requiredBytes)}, Free: ${bytesToHumanReadable(freeBytes)}`
838
+ );
839
+ }
840
+ if (freeBytes < this.freeSpaceThreshold) {
841
+ this.logger.warn?.(`Low disk space warning: only ${bytesToHumanReadable(freeBytes)} left`);
842
+ }
843
+ }
844
+ try {
845
+ await import_fs3.default.promises.writeFile(filePath, serializedData, "utf8");
846
+ this.logger.silly?.(`[FileDatabase] Wrote ${bytesToHumanReadable(requiredBytes)} to ${filePath}`);
847
+ } catch (error) {
848
+ throw new FileDatabaseError(`Failed to write file ${filePath}: ${error.message}`);
849
+ }
850
+ }
851
+ /**
852
+ * Prepare the instance for read or write operations
853
+ * This discovers state and sets up internal members based on mode and current data
854
+ */
855
+ async prepare({ write, read, version }) {
856
+ if (write) {
857
+ if (this.versioned) {
858
+ if (this.currentVersion === null) {
859
+ await this.makeNewVersion();
860
+ this.metadata = this.getDefaultMetadata();
861
+ this.metadata.version = this.currentVersion;
862
+ this.makeNewFile();
863
+ } else {
864
+ if (!this.metadata.files.length) {
865
+ this.metadata = await this.figureMetadata(this.currentVersion);
866
+ if (this.metadata.files && this.metadata.files.length > 0) {
867
+ this.currentFileNumber = Math.max(...this.metadata.files.map((f) => f.number || 0));
868
+ } else {
869
+ this.currentFileNumber = 0;
870
+ }
871
+ }
872
+ }
873
+ } else {
874
+ await ensurePath(this.getDestinationPath());
875
+ if (this.useMetadata === true) {
876
+ const metadataPath = import_path3.default.join(this.getDestinationPath(), "metadata.json");
877
+ if (import_fs3.default.existsSync(metadataPath)) {
878
+ try {
879
+ const rawData = await import_fs3.default.promises.readFile(metadataPath, "utf8");
880
+ this.metadata = JSON.parse(rawData);
881
+ if (this.metadata.files && this.metadata.files.length > 0) {
882
+ this.currentFileNumber = Math.max(...this.metadata.files.map((f) => f.number || 0));
883
+ } else {
884
+ this.currentFileNumber = 0;
885
+ }
886
+ } catch (e) {
887
+ this.metadata = this.getDefaultMetadata();
888
+ this.currentFileNumber = 0;
889
+ }
890
+ } else {
891
+ this.metadata = this.getDefaultMetadata();
892
+ this.currentFileNumber = 0;
893
+ }
894
+ } else {
895
+ this.metadata = this.getDefaultMetadata();
896
+ this.currentFileNumber = 0;
897
+ }
898
+ }
899
+ } else if (read) {
900
+ if (this.versioned) {
901
+ const versions = await this.getVersions();
902
+ if (versions.length === 0) {
903
+ throw new FileDatabaseError("[FileDatabase] No versions found, cannot read");
904
+ }
905
+ if (version) {
906
+ if (!versions.includes(version)) {
907
+ throw new FileDatabaseError(`[FileDatabase] Version "${version}" not found`);
908
+ }
909
+ await this.setCurrentVersion(version);
910
+ } else {
911
+ await this.setCurrentVersion(versions[versions.length - 1]);
912
+ }
913
+ if (!this.metadata.files.length) {
914
+ this.metadata = await this.figureMetadata(this.currentVersion);
915
+ if (this.metadata.files && this.metadata.files.length > 0) {
916
+ this.currentFileNumber = Math.max(...this.metadata.files.map((f) => f.number || 0));
917
+ } else {
918
+ this.currentFileNumber = 0;
919
+ }
920
+ }
921
+ } else {
922
+ this.currentVersion = null;
923
+ if (this.useMetadata === void 0) {
924
+ const format = await this.detectDataFormat();
925
+ this.useMetadata = format.hasMetadata;
926
+ }
927
+ if (this.useMetadata) {
928
+ const destPath = this.getDestinationPath();
929
+ const metadataPath = import_path3.default.join(destPath, "metadata.json");
930
+ if (import_fs3.default.existsSync(metadataPath)) {
931
+ try {
932
+ const rawData = await import_fs3.default.promises.readFile(metadataPath, "utf8");
933
+ this.metadata = JSON.parse(rawData);
934
+ if (this.metadata.files && this.metadata.files.length > 0) {
935
+ this.currentFileNumber = Math.max(...this.metadata.files.map((f) => f.number || 0));
936
+ } else {
937
+ this.currentFileNumber = 0;
938
+ }
939
+ } catch (e) {
940
+ throw new FileDatabaseError(`Failed to read metadata: ${e.message}`);
941
+ }
942
+ } else {
943
+ throw new FileDatabaseError(
944
+ `[FileDatabase] No metadata found in non-versioned mode. Looked for: ${metadataPath} (table path: ${destPath})`
945
+ );
946
+ }
947
+ } else {
948
+ this.metadata = await this.figureMetadataFromVersionFiles("");
949
+ if (this.metadata.files && this.metadata.files.length > 0) {
950
+ this.currentFileNumber = Math.max(...this.metadata.files.map((f) => f.number || 0));
951
+ } else {
952
+ this.currentFileNumber = 0;
953
+ }
954
+ }
955
+ }
956
+ }
957
+ }
958
+ /**
959
+ * Write data to the file database
960
+ */
961
+ async write(data, options = {}) {
962
+ if (options.filename) {
963
+ const destPath2 = this.getDestinationPath();
964
+ await ensurePath(destPath2);
965
+ const filePath = import_path3.default.join(destPath2, options.filename);
966
+ await this.safeWrite(filePath, data);
967
+ return;
968
+ }
969
+ if (options.forceNewVersion && !this.versioned) {
970
+ throw new FileDatabaseError("Cannot use forceNewVersion in non-versioned mode");
971
+ }
972
+ await this.prepare({ write: true });
973
+ const incomingDataType = detectDataType(data);
974
+ this.metadata.dataType = incomingDataType;
975
+ if (options.forceNewVersion) {
976
+ await this.makeNewVersion();
977
+ this.metadata = this.getDefaultMetadata();
978
+ this.metadata.version = this.currentVersion;
979
+ this.metadata.dataType = incomingDataType;
980
+ this.makeNewFile();
981
+ }
982
+ let targetFileIndex = null;
983
+ const hasCustomMetadata = options.customMetadata && Object.keys(options.customMetadata).length > 0;
984
+ if (hasCustomMetadata) {
985
+ for (let i = 0; i < this.metadata.files.length; i++) {
986
+ const fileEntry = this.metadata.files[i];
987
+ const matches = Object.keys(options.customMetadata).every((key) => {
988
+ return key in fileEntry && fileEntry[key] === options.customMetadata[key];
989
+ });
990
+ if (matches) {
991
+ targetFileIndex = i;
992
+ this.logger.silly?.(`[FileDatabase] Found existing file with matching custom metadata: ${fileEntry.fileName}, metadata: ${JSON.stringify(options.customMetadata)}`);
993
+ break;
994
+ } else {
995
+ this.logger.silly?.(`[FileDatabase] File ${fileEntry.fileName} does not match custom metadata: ${JSON.stringify(options.customMetadata)}`);
996
+ }
997
+ }
998
+ if (targetFileIndex === null) {
999
+ this.logger.silly?.(`[FileDatabase] No existing file found with custom metadata: ${JSON.stringify(options.customMetadata)}, will create new file`);
1000
+ }
1001
+ } else {
1002
+ this.logger.silly?.(`[FileDatabase] No custom metadata provided, will create new file`);
1003
+ }
1004
+ if (targetFileIndex !== null) {
1005
+ const targetFile = this.metadata.files[targetFileIndex];
1006
+ this.currentFileNumber = targetFile.number;
1007
+ this.lastFileData = null;
1008
+ this.currentRecord = 0;
1009
+ this.hasReadFirstPage = false;
1010
+ }
1011
+ const forceNewFile = hasCustomMetadata && targetFileIndex === null;
1012
+ let { dataToWrite, dataLeftOver, fileName } = this.figureOutDataAndFileToWrite(data, targetFileIndex, forceNewFile);
1013
+ const destPath = this.getDestinationPath(this.currentVersion || void 0);
1014
+ await this.safeWrite(import_path3.default.join(destPath, fileName), dataToWrite);
1015
+ this.updateMetadata(dataToWrite, fileName, options.customMetadata);
1016
+ while (dataLeftOver && dataLeftOver.length > 0 && targetFileIndex === null) {
1017
+ const writeContext = this.figureOutDataAndFileToWrite(dataLeftOver);
1018
+ await this.safeWrite(import_path3.default.join(destPath, writeContext.fileName), writeContext.dataToWrite);
1019
+ this.updateMetadata(writeContext.dataToWrite, writeContext.fileName, options.customMetadata);
1020
+ dataLeftOver = writeContext.dataLeftOver;
1021
+ }
1022
+ this.calculateVersionSynopsis();
1023
+ if (this.useMetadata) {
1024
+ await this.saveVersionMetadata(this.metadata);
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Read data from the file database
1029
+ */
1030
+ async read(options = {}) {
1031
+ const { version, nextPage = false, pageSize, filename } = options;
1032
+ if (filename) {
1033
+ const destPath = this.getDestinationPath(version);
1034
+ const filePath = import_path3.default.join(destPath, filename);
1035
+ try {
1036
+ const rawData = await import_fs3.default.promises.readFile(filePath, "utf8");
1037
+ return JSON.parse(rawData);
1038
+ } catch (error) {
1039
+ throw new FileDatabaseError(`Failed to read file ${filename}: ${error.message}`);
1040
+ }
1041
+ }
1042
+ await this.prepare({ read: true, version });
1043
+ const isNonPaginatedData = this.metadata.dataType === "text" || this.metadata.dataType === "xml" || this.metadata.dataType === "json-object";
1044
+ if (isNonPaginatedData) {
1045
+ const file = this.metadata.files[0];
1046
+ const filePath = import_path3.default.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
1047
+ try {
1048
+ const rawData = await import_fs3.default.promises.readFile(filePath, "utf8");
1049
+ return deserializeData(rawData, this.metadata.dataType);
1050
+ } catch (error) {
1051
+ throw new FileDatabaseError(`Failed to read file ${file.fileName}: ${error.message}`);
1052
+ }
1053
+ }
1054
+ let effectivePageSize;
1055
+ if (nextPage && this.hasReadFirstPage) {
1056
+ effectivePageSize = pageSize || this.pageSize;
1057
+ this.currentRecord += effectivePageSize;
1058
+ } else if (!nextPage) {
1059
+ effectivePageSize = pageSize !== void 0 ? pageSize : this.metadata.totalRecords;
1060
+ this.currentRecord = 0;
1061
+ } else {
1062
+ effectivePageSize = pageSize || this.pageSize;
1063
+ }
1064
+ if (this.currentRecord >= this.metadata.totalRecords) {
1065
+ return [];
1066
+ }
1067
+ const result = [];
1068
+ let recordsRead = 0;
1069
+ let currentFileIndex = 0;
1070
+ let currentFileOffset = 0;
1071
+ let totalRecords = 0;
1072
+ for (let i = 0; i < this.metadata.files.length; i++) {
1073
+ const file = this.metadata.files[i];
1074
+ if (this.currentRecord < totalRecords + file.recordsCount) {
1075
+ currentFileIndex = i;
1076
+ currentFileOffset = totalRecords;
1077
+ break;
1078
+ }
1079
+ totalRecords += file.recordsCount;
1080
+ }
1081
+ let cumulativeRecords = currentFileOffset;
1082
+ for (let i = currentFileIndex; i < this.metadata.files.length && recordsRead < effectivePageSize; i++) {
1083
+ const file = this.metadata.files[i];
1084
+ const filePath = import_path3.default.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
1085
+ try {
1086
+ const rawData = await import_fs3.default.promises.readFile(filePath, "utf8");
1087
+ const fileData = deserializeData(rawData, this.metadata.dataType);
1088
+ let startIndex = 0;
1089
+ if (i === currentFileIndex) {
1090
+ startIndex = this.currentRecord - cumulativeRecords;
1091
+ }
1092
+ const endIndex = Math.min(startIndex + (effectivePageSize - recordsRead), fileData.length);
1093
+ const recordsFromThisFile = fileData.slice(startIndex, endIndex);
1094
+ result.push(...recordsFromThisFile);
1095
+ recordsRead += recordsFromThisFile.length;
1096
+ cumulativeRecords += file.recordsCount;
1097
+ } catch (error) {
1098
+ throw new FileDatabaseError(`Failed to read file ${file.fileName}: ${error.message}`);
1099
+ }
1100
+ }
1101
+ if (result.length > 0) {
1102
+ if (nextPage || pageSize !== void 0 && pageSize < this.metadata.totalRecords) {
1103
+ this.hasReadFirstPage = true;
1104
+ }
1105
+ }
1106
+ return result;
1107
+ }
1108
+ /**
1109
+ * Set the starting record for pagination (1-based index)
1110
+ */
1111
+ setStartRecord(startRecord) {
1112
+ this.currentRecord = startRecord - 1;
1113
+ this.hasReadFirstPage = false;
1114
+ }
1115
+ /**
1116
+ * Reset read pagination state
1117
+ */
1118
+ resetPagination() {
1119
+ this.currentRecord = 0;
1120
+ this.hasReadFirstPage = false;
1121
+ }
1122
+ /**
1123
+ * List filenames in the table directory.
1124
+ * For catalog/key-value usage (files written with { filename }).
1125
+ * Returns data file names (.json, .txt, .xml) excluding metadata.json.
1126
+ */
1127
+ async listFilenames() {
1128
+ const destPath = this.versioned && this.currentVersion ? import_path3.default.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
1129
+ try {
1130
+ const entries = await import_fs3.default.promises.readdir(destPath, { withFileTypes: true });
1131
+ return entries.filter((e) => e.isFile() && e.name !== "metadata.json" && /\.(json|txt|xml)$/i.test(e.name)).map((e) => e.name);
1132
+ } catch (err) {
1133
+ if (err?.code === "ENOENT") return [];
1134
+ throw new FileDatabaseError(`Failed to list files: ${err.message}`);
1135
+ }
1136
+ }
1137
+ /**
1138
+ * Remove a file from the table directory (catalog mode).
1139
+ * Use with listFilenames() to manage individual files.
1140
+ */
1141
+ async removeFile(filename) {
1142
+ const destPath = this.versioned && this.currentVersion ? import_path3.default.join(this.getDestinationPath(), this.currentVersion) : this.getDestinationPath();
1143
+ const filePath = import_path3.default.join(destPath, filename);
1144
+ try {
1145
+ await import_fs3.default.promises.unlink(filePath);
1146
+ } catch (err) {
1147
+ if (err?.code === "ENOENT") return;
1148
+ throw new FileDatabaseError(`Failed to remove file ${filename}: ${err.message}`);
1149
+ }
1150
+ }
1151
+ /**
1152
+ * Remove a file and its metadata entry (non-versioned mode with useMetadata).
1153
+ * Use with findData() to get fileName, then call removeFileEntry to delete.
1154
+ */
1155
+ async removeFileEntry(filename) {
1156
+ if (this.versioned) {
1157
+ throw new FileDatabaseError("removeFileEntry is only supported in non-versioned mode");
1158
+ }
1159
+ await this.prepare({ read: true });
1160
+ const idx = this.metadata.files.findIndex((f) => f.fileName === filename);
1161
+ if (idx === -1) {
1162
+ throw new FileDatabaseError(`File entry ${filename} not found in metadata`);
1163
+ }
1164
+ const entry = this.metadata.files[idx];
1165
+ const recordsCount = entry.recordsCount || 0;
1166
+ this.metadata.files.splice(idx, 1);
1167
+ this.metadata.totalRecords = Math.max(0, (this.metadata.totalRecords || 0) - recordsCount);
1168
+ const destPath = this.getDestinationPath();
1169
+ const filePath = import_path3.default.join(destPath, filename);
1170
+ try {
1171
+ await import_fs3.default.promises.unlink(filePath);
1172
+ } catch (err) {
1173
+ if (err?.code === "ENOENT") {
1174
+ this.logger.warn?.(`[FileDatabase] File ${filename} already missing on disk`);
1175
+ } else {
1176
+ throw new FileDatabaseError(`Failed to remove file ${filename}: ${err.message}`);
1177
+ }
1178
+ }
1179
+ if (this.useMetadata) {
1180
+ await this.saveVersionMetadata(this.metadata);
1181
+ }
1182
+ }
1183
+ /**
1184
+ * Set file-level synopsis calculation function
1185
+ */
1186
+ setFileSynopsisFunction(fn) {
1187
+ this.fileSynopsisFunction = fn;
1188
+ }
1189
+ /**
1190
+ * Set version-level synopsis calculation function
1191
+ */
1192
+ setVersionSynopsisFunction(fn) {
1193
+ this.versionSynopsisFunction = fn;
1194
+ }
1195
+ /**
1196
+ * Get current version name
1197
+ */
1198
+ getCurrentVersion() {
1199
+ return this.currentVersion;
1200
+ }
1201
+ /**
1202
+ * Get current metadata
1203
+ */
1204
+ getMetadata() {
1205
+ return { ...this.metadata };
1206
+ }
1207
+ /**
1208
+ * Find data by custom metadata fields
1209
+ * Searches through all versions and files to find entries matching the search criteria
1210
+ *
1211
+ * @param searchCriteria - Object with field names and values to search for (e.g., { ListingKey: "123", id: "456" })
1212
+ * @returns Array of found entries with their file paths and metadata
1213
+ */
1214
+ async findData(searchCriteria) {
1215
+ const results = [];
1216
+ if (!this.versioned) {
1217
+ await this.prepare({ read: true });
1218
+ const metadata = this.getMetadata();
1219
+ for (const fileEntry of metadata.files) {
1220
+ const matches = Object.keys(searchCriteria).every((key) => {
1221
+ return fileEntry[key] === searchCriteria[key];
1222
+ });
1223
+ if (matches) {
1224
+ const destPath = this.getDestinationPath();
1225
+ const filePath = import_path3.default.join(destPath, fileEntry.fileName);
1226
+ const fileData = await import_fs3.default.promises.readFile(filePath, "utf8");
1227
+ const data = deserializeData(fileData, metadata.dataType || "json-object");
1228
+ results.push({
1229
+ filePath,
1230
+ fileName: fileEntry.fileName,
1231
+ version: null,
1232
+ metadata: fileEntry,
1233
+ data
1234
+ });
1235
+ }
1236
+ }
1237
+ } else {
1238
+ const versions = await this.getVersions();
1239
+ for (const version of versions) {
1240
+ await this.prepare({ read: true, version });
1241
+ const metadata = this.getMetadata();
1242
+ for (const fileEntry of metadata.files) {
1243
+ const matches = Object.keys(searchCriteria).every((key) => {
1244
+ return fileEntry[key] === searchCriteria[key];
1245
+ });
1246
+ if (matches) {
1247
+ const destPath = this.getDestinationPath(version);
1248
+ const filePath = import_path3.default.join(destPath, fileEntry.fileName);
1249
+ const fileData = await import_fs3.default.promises.readFile(filePath, "utf8");
1250
+ const data = deserializeData(fileData, metadata.dataType || "json-object");
1251
+ results.push({
1252
+ filePath,
1253
+ fileName: fileEntry.fileName,
1254
+ version,
1255
+ metadata: fileEntry,
1256
+ data
1257
+ });
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+ return results;
1263
+ }
1264
+ };
1265
+
1266
+ // src/mock-server/mock-storage.ts
1267
+ function stableStringify(obj) {
1268
+ if (obj === null) return "null";
1269
+ if (obj === void 0) return "undefined";
1270
+ if (typeof obj !== "object") return JSON.stringify(obj);
1271
+ if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
1272
+ const keys = Object.keys(obj).sort();
1273
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
1274
+ }
1275
+ function normalizeQuery(query) {
1276
+ if (!query) return "";
1277
+ const params = new URLSearchParams(query);
1278
+ const sorted = [...params.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1279
+ return new URLSearchParams(sorted).toString();
1280
+ }
1281
+ function buildCriteria(method, host, pathname, query, requestData) {
1282
+ return {
1283
+ method: method.toUpperCase(),
1284
+ host,
1285
+ pathname: pathname || "/",
1286
+ query: normalizeQuery(query),
1287
+ requestBody: requestData != null ? stableStringify(requestData) : ""
1288
+ };
1289
+ }
1290
+ var MockStorage = class {
1291
+ fileDb;
1292
+ logger;
1293
+ constructor(config) {
1294
+ this.logger = config.logger ?? console;
1295
+ this.fileDb = new FileDatabase({
1296
+ basePath: config.basePath,
1297
+ namespace: "mocks",
1298
+ tableName: "responses",
1299
+ versioned: false,
1300
+ useMetadata: true,
1301
+ logger: this.logger
1302
+ });
1303
+ }
1304
+ /**
1305
+ * Store a mock response.
1306
+ */
1307
+ async store(method, requestUrl, requestData, responseData) {
1308
+ const url = new URL(requestUrl);
1309
+ const criteria = buildCriteria(method, url.host, url.pathname, url.search.slice(1), requestData);
1310
+ await this.fileDb.write(responseData, { customMetadata: criteria });
1311
+ }
1312
+ /**
1313
+ * Find a mock response by request criteria. Returns null if not found.
1314
+ */
1315
+ async find(method, host, pathname, query, requestData) {
1316
+ const criteria = buildCriteria(method, host, pathname, query, requestData);
1317
+ try {
1318
+ const results = await this.fileDb.findData(criteria);
1319
+ if (results.length === 0) return null;
1320
+ return results[0].data;
1321
+ } catch (err) {
1322
+ if (err instanceof FileDatabaseError && /No metadata found/.test(err.message)) {
1323
+ return null;
1324
+ }
1325
+ throw err;
1326
+ }
1327
+ }
1328
+ /**
1329
+ * List all stored mock keys (opaque identifiers for remove)
1330
+ */
1331
+ async listKeys() {
1332
+ const files = await this.fileDb.listFilenames();
1333
+ return files.sort();
1334
+ }
1335
+ /**
1336
+ * Remove a mock by key (from listKeys)
1337
+ */
1338
+ async remove(fileName) {
1339
+ const name = fileName.endsWith(".json") ? fileName : `${fileName}.json`;
1340
+ try {
1341
+ await this.fileDb.removeFileEntry(name);
1342
+ return true;
1343
+ } catch {
1344
+ return false;
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Remove a mock by request criteria (method, host, pathname, query, requestData)
1349
+ */
1350
+ async removeByCriteria(method, host, pathname, query, requestData) {
1351
+ const criteria = buildCriteria(method, host, pathname, query, requestData);
1352
+ try {
1353
+ const results = await this.fileDb.findData(criteria);
1354
+ if (results.length === 0) return false;
1355
+ await this.fileDb.removeFileEntry(results[0].fileName);
1356
+ return true;
1357
+ } catch {
1358
+ return false;
1359
+ }
1360
+ }
1361
+ };
1362
+
143
1363
  // src/http-client2/index.ts
144
1364
  function buildUrl(baseURL, url, params) {
145
1365
  let full = url;
@@ -192,14 +1412,50 @@ function mapStatusToCustom(httpStatus) {
192
1412
  if (httpStatus >= 500) return "serverError";
193
1413
  return "unknown";
194
1414
  }
1415
+ function formatForDisplay(value, maxKeys, maxArrayItems, maxChars) {
1416
+ if (value === null) return "null";
1417
+ if (value === void 0) return "undefined";
1418
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
1419
+ if (typeof value === "string") {
1420
+ if (value.length <= maxChars) return JSON.stringify(value);
1421
+ return JSON.stringify(value.slice(0, maxChars) + `... (+${value.length - maxChars} chars)`);
1422
+ }
1423
+ if (Array.isArray(value)) {
1424
+ const head = value.slice(0, maxArrayItems);
1425
+ const rest = value.length - maxArrayItems;
1426
+ const items = head.map((v) => formatForDisplay(v, maxKeys, maxArrayItems, maxChars));
1427
+ if (rest <= 0) return `[${items.join(", ")}]`;
1428
+ return `[${items.join(", ")}, ... +${rest} more]`;
1429
+ }
1430
+ if (typeof value === "object") {
1431
+ const keys = Object.keys(value);
1432
+ const shown = keys.slice(0, maxKeys);
1433
+ const rest = keys.length - maxKeys;
1434
+ const pairs = shown.map((k) => `${JSON.stringify(k)}: ${formatForDisplay(value[k], maxKeys, maxArrayItems, maxChars)}`);
1435
+ if (rest <= 0) return `{${pairs.join(", ")}}`;
1436
+ return `{${pairs.join(", ")}, ... +${rest} more keys}`;
1437
+ }
1438
+ return String(value);
1439
+ }
195
1440
  var HttpClient = class _HttpClient {
196
1441
  context;
197
1442
  config;
198
1443
  logger;
1444
+ mockStorage = null;
199
1445
  constructor(context, options = {}) {
200
1446
  this.context = context;
201
1447
  this.config = options;
202
1448
  this.logger = options.logger ?? context?.logger ?? console;
1449
+ if (options.saveMock || options.useMock) {
1450
+ const mocksPath = options.mocksPath;
1451
+ if (!mocksPath) {
1452
+ throw new ParamError("[http-client2] mocksPath is required when saveMock or useMock is set");
1453
+ }
1454
+ const absoluteMocksPath = import_path4.default.resolve(mocksPath);
1455
+ this.mockStorage = new MockStorage({ basePath: absoluteMocksPath, logger: this.logger });
1456
+ const mode = [options.saveMock && "saveMock", options.useMock && "useMock"].filter(Boolean).join(", ");
1457
+ this.logger.debug?.(`[HttpClient] mocks enabled (${mode}), mocksPath=${absoluteMocksPath}`);
1458
+ }
203
1459
  }
204
1460
  /**
205
1461
  * Static init - discovers params via context.params.getAllForModule("http-client2", defs). Whatever is in options goes.
@@ -212,27 +1468,85 @@ var HttpClient = class _HttpClient {
212
1468
  maxRetryDelay: "number default 30000",
213
1469
  retryJitter: "number default 0.1",
214
1470
  userAgent: "string default HttpClient/v1.0",
215
- baseURL: "string"
1471
+ baseURL: "string",
1472
+ saveMock: "boolean default false",
1473
+ useMock: "boolean default false",
1474
+ mocksPath: "string",
1475
+ useTestServer: "string",
1476
+ showRequest: "boolean default false",
1477
+ showResponse: "boolean default false",
1478
+ showRequestHeaders: "boolean default false",
1479
+ showResponseHeaders: "boolean default false",
1480
+ showMaxKeys: "number default 20",
1481
+ showMaxArrayItems: "number default 5",
1482
+ showMaxChars: "number default 300"
216
1483
  };
217
1484
  const discovered = context?.params?.getAllForModule?.(defs) ?? {};
218
1485
  const merged = { ...discovered, ...options };
1486
+ if ((merged.saveMock || merged.useMock) && !merged.mocksPath) {
1487
+ throw new ParamError("[http-client2] mocksPath is required when saveMock or useMock is set");
1488
+ }
1489
+ if ((merged.saveMock || merged.useMock) && merged.useTestServer) {
1490
+ throw new ParamError("[http-client2] saveMock/useMock cannot be used with useTestServer");
1491
+ }
219
1492
  return new _HttpClient(context, merged);
220
1493
  }
221
1494
  async request(method, url, options = {}) {
222
1495
  const startTime = Date.now();
223
1496
  const timeout = options.timeout ?? this.config.timeout;
224
- const retryCount = options.retryCount ?? this.config.retryCount;
225
- const retryDelay = options.retryDelay ?? this.config.retryDelay;
226
- const fullUrl = buildUrl(this.config.baseURL, url, options.params);
1497
+ const retryCount = options.retryCount ?? this.config.retryCount ?? 3;
1498
+ const retryDelay = options.retryDelay ?? this.config.retryDelay ?? 1e3;
1499
+ let fullUrl = buildUrl(this.config.baseURL, url, options.params);
1500
+ if (this.config.useMock && this.mockStorage) {
1501
+ try {
1502
+ const urlObj = new URL(fullUrl);
1503
+ const mock = await this.mockStorage.find(
1504
+ method,
1505
+ urlObj.host,
1506
+ urlObj.pathname,
1507
+ urlObj.search.slice(1),
1508
+ options.data
1509
+ );
1510
+ if (mock) {
1511
+ const duration = Date.now() - startTime;
1512
+ if (this.config.showResponse) {
1513
+ this.logRequestResponse(method, fullUrl, options, null, mock, duration, true);
1514
+ }
1515
+ return {
1516
+ status: mapStatusToCustom(mock.status),
1517
+ code: mock.status,
1518
+ headers: mock.headers || {},
1519
+ data: mock.data,
1520
+ duration,
1521
+ retryCount: 0,
1522
+ finalUrl: fullUrl
1523
+ };
1524
+ }
1525
+ throw new HttpClientError(`Mock not found for ${method} ${fullUrl}`);
1526
+ } catch (e) {
1527
+ if (e instanceof HttpClientError) throw e;
1528
+ this.logger.warn?.("[HttpClient] Mock lookup failed:", e);
1529
+ throw new HttpClientError(`Mock not found for ${method} ${fullUrl} (lookup failed: ${e.message})`);
1530
+ }
1531
+ }
1532
+ let fetchUrl = fullUrl;
227
1533
  const headers = {
228
- "User-Agent": options.userAgent ?? this.config.userAgent,
1534
+ "User-Agent": options.userAgent ?? this.config.userAgent ?? "HttpClient/v1.0",
229
1535
  ...options.headers
230
1536
  };
1537
+ if (this.config.useTestServer) {
1538
+ const urlObj = new URL(fullUrl);
1539
+ fetchUrl = this.config.useTestServer.replace(/\/$/, "") + urlObj.pathname + urlObj.search;
1540
+ headers["XAXIOSOrigin"] = fullUrl;
1541
+ }
231
1542
  let body;
232
1543
  if (options.data != null && ["POST", "PUT", "PATCH"].includes(method)) {
233
1544
  body = typeof options.data === "string" ? options.data : JSON.stringify(options.data);
234
1545
  if (!headers["content-type"]) headers["Content-Type"] = "application/json";
235
1546
  }
1547
+ if (this.config.showRequest) {
1548
+ this.logRequestResponse(method, fullUrl, options, headers, null, 0, false);
1549
+ }
236
1550
  let lastError = null;
237
1551
  for (let attempt = 1; attempt <= retryCount + 1; attempt++) {
238
1552
  let abortedByTimeout = false;
@@ -245,7 +1559,7 @@ var HttpClient = class _HttpClient {
245
1559
  abortedByTimeout = true;
246
1560
  controller.abort();
247
1561
  }, timeout);
248
- const response = await fetch(fullUrl, {
1562
+ const response = await fetch(fetchUrl, {
249
1563
  method,
250
1564
  headers,
251
1565
  body: method !== "GET" && method !== "HEAD" ? body : void 0,
@@ -260,7 +1574,7 @@ var HttpClient = class _HttpClient {
260
1574
  if (options.debug) {
261
1575
  this.logger.debug?.(`[HttpClient] ${method} ${fullUrl} \u2192 ${response.status} success (${duration}ms)`);
262
1576
  }
263
- return {
1577
+ const result = {
264
1578
  status: mapStatusToCustom(response.status),
265
1579
  code: response.status,
266
1580
  headers: resHeaders,
@@ -269,14 +1583,38 @@ var HttpClient = class _HttpClient {
269
1583
  retryCount: attempt - 1,
270
1584
  finalUrl: response.url || fullUrl
271
1585
  };
1586
+ if (this.config.saveMock && this.mockStorage) {
1587
+ try {
1588
+ const responseData = {
1589
+ status: response.status,
1590
+ headers: resHeaders,
1591
+ data: data2
1592
+ };
1593
+ await this.mockStorage.store(
1594
+ method,
1595
+ fullUrl,
1596
+ options.data,
1597
+ responseData
1598
+ );
1599
+ } catch (e) {
1600
+ this.logger.warn?.("[HttpClient] Failed to save mock:", e);
1601
+ }
1602
+ }
1603
+ if (this.config.showResponse) {
1604
+ this.logRequestResponse(method, fullUrl, options, headers, { status: response.status, headers: resHeaders, data: data2 }, duration, false);
1605
+ }
1606
+ return result;
272
1607
  }
273
1608
  const data = await parseBody(response);
274
1609
  const classification2 = classifyError({ response: { status: response.status, headers: resHeaders, data } });
1610
+ if (this.config.showResponse) {
1611
+ this.logRequestResponse(method, fullUrl, options, headers, { status: response.status, headers: resHeaders, data }, duration, false);
1612
+ }
275
1613
  if (options.debug) {
276
1614
  this.logger.debug?.(`[HttpClient] ${method} ${fullUrl} \u2192 ${response.status} ${classification2.status} (${duration}ms)`);
277
1615
  }
278
1616
  if (attempt <= retryCount && shouldRetryError(classification2)) {
279
- const delay = calculateRetryDelay(attempt, retryDelay, this.config.maxRetryDelay, this.config.retryJitter);
1617
+ const delay = calculateRetryDelay(attempt, retryDelay, this.config.maxRetryDelay ?? 3e4, this.config.retryJitter ?? 0.1);
280
1618
  this.logger.warn?.(`[HttpClient] ${method} ${fullUrl} failed (${classification2.type}). Retrying in ${delay}ms...`);
281
1619
  if (options.debug) this.logger.debug?.(`[HttpClient] Waiting ${delay}ms before retry ${attempt + 1}`);
282
1620
  await sleep(delay);
@@ -306,7 +1644,7 @@ var HttpClient = class _HttpClient {
306
1644
  this.logger.error?.(`[HttpClient] ${method} ${fullUrl} failed (${classification2.type}): ${getErrorDescription(classification2.type)}`);
307
1645
  }
308
1646
  if (attempt <= retryCount && shouldRetryError(classification2)) {
309
- const delay = calculateRetryDelay(attempt, retryDelay, this.config.maxRetryDelay, this.config.retryJitter);
1647
+ const delay = calculateRetryDelay(attempt, retryDelay, this.config.maxRetryDelay ?? 3e4, this.config.retryJitter ?? 0.1);
310
1648
  if (options.debug) this.logger.debug?.(`[HttpClient] Waiting ${delay}ms before retry ${attempt + 1}`);
311
1649
  await sleep(delay);
312
1650
  continue;
@@ -335,6 +1673,28 @@ var HttpClient = class _HttpClient {
335
1673
  finalUrl: fullUrl
336
1674
  };
337
1675
  }
1676
+ logRequestResponse(method, url, options, reqHeaders, res, durationMs, fromMock) {
1677
+ const maxKeys = this.config.showMaxKeys ?? 20;
1678
+ const maxArray = this.config.showMaxArrayItems ?? 5;
1679
+ const maxChars = this.config.showMaxChars ?? 300;
1680
+ const log = this.logger.info ?? this.logger.log ?? console.log;
1681
+ log(`[HttpClient] ${method} ${url}`);
1682
+ if (this.config.showRequestHeaders && reqHeaders && Object.keys(reqHeaders).length > 0) {
1683
+ log(` Request headers: ${formatForDisplay(reqHeaders, maxKeys, maxArray, maxChars)}`);
1684
+ }
1685
+ if (options.data != null) {
1686
+ log(` Request body: ${formatForDisplay(options.data, maxKeys, maxArray, maxChars)}`);
1687
+ }
1688
+ if (res) {
1689
+ log(` \u2192 ${res.status}${fromMock ? " (from mock)" : ""} ${durationMs}ms`);
1690
+ if (this.config.showResponseHeaders && res.headers && Object.keys(res.headers).length > 0) {
1691
+ log(` Response headers: ${formatForDisplay(res.headers, maxKeys, maxArray, maxChars)}`);
1692
+ }
1693
+ if (res.data != null) {
1694
+ log(` Response body: ${formatForDisplay(res.data, maxKeys, maxArray, maxChars)}`);
1695
+ }
1696
+ }
1697
+ }
338
1698
  async get(url, options = {}) {
339
1699
  return this.request("GET", url, options);
340
1700
  }