@crustjs/store 0.4.2 → 0.4.4

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +61 -46
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @crustjs/store
2
2
 
3
- DX-first, typed persistence for CLI apps with config/data/state/cache separation
3
+ Typed JSON persistence for CLI config, data, state, and cache files
4
4
 
5
5
  ## Install
6
6
 
package/dist/index.js CHANGED
@@ -70,13 +70,19 @@ function getRuntimeEnv() {
70
70
  homedir: homedir()
71
71
  };
72
72
  }
73
- /** Validates a path input according to its role. */
74
- function validatePath(value, field) {
73
+ function validateNonEmpty(value, field) {
75
74
  if (!value.trim()) throw new CrustStoreError("PATH", `${field} must be a non-empty string`, { path: value });
76
- if (field !== "dirPath" && (value.includes("/") || value.includes("\\"))) throw new CrustStoreError("PATH", `${field} must not contain path separators`, { path: value });
77
- if (field === "dirPath" && !win32.isAbsolute(value)) throw new CrustStoreError("PATH", "dirPath must be an absolute path", { path: value });
75
+ }
76
+ /** Validates a single path segment: `appName` or the store `name`. */
77
+ function validateSegment(value, field) {
78
+ validateNonEmpty(value, field);
79
+ if (value.includes("/") || value.includes("\\")) throw new CrustStoreError("PATH", `${field} must not contain path separators`, { path: value });
78
80
  if (field === "name" && value.endsWith(".json")) throw new CrustStoreError("PATH", "name must not include the .json extension", { path: value });
79
- if (field === "dirPath" && value.endsWith(".json")) throw new CrustStoreError("PATH", "dirPath must be a directory path, not a file path (should not end with .json)", { path: value });
81
+ }
82
+ function validateDirPath(value) {
83
+ validateNonEmpty(value, "dirPath");
84
+ if (!win32.isAbsolute(value)) throw new CrustStoreError("PATH", "dirPath must be an absolute path", { path: value });
85
+ if (value.endsWith(".json")) throw new CrustStoreError("PATH", "dirPath must be a directory path, not a file path (should not end with .json)", { path: value });
80
86
  }
81
87
  const DIRECTORY_CONFIG = {
82
88
  config: {
@@ -109,7 +115,7 @@ const DIRECTORY_CONFIG = {
109
115
  }
110
116
  };
111
117
  function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
112
- validatePath(appName, "appName");
118
+ validateSegment(appName, "appName");
113
119
  const config = DIRECTORY_CONFIG[kind];
114
120
  if (env.platform === "linux" || env.platform === "darwin") {
115
121
  const configured = env.env[config.xdg];
@@ -248,8 +254,8 @@ function cacheDir(appName, env) {
248
254
  * @throws {CrustStoreError} `PATH` if `dirPath` or `name` is invalid.
249
255
  */
250
256
  function resolveStorePath(dirPath, name) {
251
- validatePath(dirPath, "dirPath");
252
- validatePath(name, "name");
257
+ validateDirPath(dirPath);
258
+ validateSegment(name, "name");
253
259
  return join(dirPath, `${name}.json`);
254
260
  }
255
261
  //#endregion
@@ -287,7 +293,7 @@ function setDocumentValue(document, key, value) {
287
293
  function applyFieldDefaults(persisted, fields, pruneUnknown = true) {
288
294
  const result = {};
289
295
  for (const [key, def] of Object.entries(fields)) if (persisted && Object.hasOwn(persisted, key)) setDocumentValue(result, key, persisted[key]);
290
- else if ("default" in def && def.default !== void 0) setDocumentValue(result, key, structuredClone(def.default));
296
+ else if (def.default !== void 0) setDocumentValue(result, key, structuredClone(def.default));
291
297
  if (!pruneUnknown && persisted !== void 0) {
292
298
  for (const [key, value] of Object.entries(persisted)) if (!Object.hasOwn(fields, key)) setDocumentValue(result, key, value);
293
299
  }
@@ -312,7 +318,7 @@ async function readJson(filePath) {
312
318
  try {
313
319
  raw = await readFile(filePath, "utf-8");
314
320
  } catch (err) {
315
- if (isEnoent(err)) return;
321
+ if (isErrnoException(err) && err.code === "ENOENT") return;
316
322
  throw new CrustStoreError("IO", `Failed to read config file: ${filePath}`, {
317
323
  path: filePath,
318
324
  operation: "read"
@@ -409,12 +415,6 @@ async function deleteJson(filePath) {
409
415
  }, err);
410
416
  }
411
417
  }
412
- /**
413
- * Checks whether a thrown error is a filesystem "file not found" error.
414
- */
415
- function isEnoent(cause) {
416
- return isErrnoException(cause) && cause.code === "ENOENT";
417
- }
418
418
  //#endregion
419
419
  //#region src/store.ts
420
420
  function makeSchemaValidator(schema) {
@@ -440,6 +440,46 @@ function matchesDeclaredType(def, value) {
440
440
  function expectedTypeMessage(def) {
441
441
  return `Expected ${def.type}${def.array === true ? "[]" : ""}`;
442
442
  }
443
+ function coerceByType(value, type) {
444
+ if (type === "number" && typeof value === "string") return tryCoerceNumber(value) ?? value;
445
+ if (type === "boolean" && typeof value === "string") return coerceBooleanString(value);
446
+ return value;
447
+ }
448
+ /**
449
+ * Re-validates a changed transform output once. This catches cross-type
450
+ * transforms like `z.string().transform(Number)` whose output would fail the
451
+ * schema on the next read.
452
+ *
453
+ * @returns The issue message when the next read would see a different value.
454
+ */
455
+ async function readUnstableTransformMessage(validator, transformed) {
456
+ let recheck;
457
+ try {
458
+ recheck = await validator(transformed);
459
+ } catch (cause) {
460
+ return `read-unstable transform: ${cause instanceof Error ? cause.message : "re-validation failed"}`;
461
+ }
462
+ if (recheck !== void 0 && (!isFieldValueResult(recheck) || !isDeepStrictEqual(recheck.value, transformed))) return "read-unstable transform: output would be transformed again on re-read";
463
+ }
464
+ /**
465
+ * Mutations promise to return exactly what the next read() sees, so any
466
+ * value JSON serialization would alter (NaN/Infinity → null, -0 → 0,
467
+ * array holes → null, dropped undefined object properties) is rejected.
468
+ */
469
+ function jsonInstabilityIssues(document) {
470
+ const issues = [];
471
+ for (const [key, value] of Object.entries(document)) {
472
+ if (value === void 0) continue;
473
+ try {
474
+ if (isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value)) continue;
475
+ } catch {}
476
+ issues.push({
477
+ message: "value does not survive JSON serialization (cycles, NaN, Infinity, -0, sparse arrays, or undefined properties)",
478
+ path: key
479
+ });
480
+ }
481
+ return issues;
482
+ }
443
483
  /**
444
484
  * Creates a typed async config store backed by a local JSON file.
445
485
  *
@@ -484,7 +524,6 @@ function createStore(options) {
484
524
  validators.set(key, makeSchemaValidator(def.schema));
485
525
  } else if (def.validate !== void 0) validators.set(key, def.validate);
486
526
  const filePath = resolveStorePath(dirPath, name);
487
- const shouldPrune = pruneUnknown ?? true;
488
527
  const writeOptions = access === "private" ? {
489
528
  fileMode: 384,
490
529
  directoryMode: 448
@@ -492,11 +531,6 @@ function createStore(options) {
492
531
  fileMode: access.file,
493
532
  directoryMode: access.directory
494
533
  };
495
- function coerceByType(value, type) {
496
- if (type === "number" && typeof value === "string") return tryCoerceNumber(value) ?? value;
497
- if (type === "boolean" && typeof value === "string") return coerceBooleanString(value);
498
- return value;
499
- }
500
534
  function normalizeStateTypes(state) {
501
535
  const normalized = { ...state };
502
536
  for (const [key, def] of Object.entries(fields)) {
@@ -561,20 +595,10 @@ function createStore(options) {
561
595
  continue;
562
596
  }
563
597
  if (!isDeepStrictEqual(transformed, value)) {
564
- let recheck;
565
- try {
566
- recheck = await validator(transformed);
567
- } catch (cause) {
568
- const message = cause instanceof Error ? cause.message : "re-validation failed";
598
+ const unstable = await readUnstableTransformMessage(validator, transformed);
599
+ if (unstable !== void 0) {
569
600
  issues.push({
570
- message: `read-unstable transform: ${message}`,
571
- path: key
572
- });
573
- continue;
574
- }
575
- if (recheck !== void 0 && (!isFieldValueResult(recheck) || !isDeepStrictEqual(recheck.value, transformed))) {
576
- issues.push({
577
- message: `read-unstable transform: output would be transformed again on re-read`,
601
+ message: unstable,
578
602
  path: key
579
603
  });
580
604
  continue;
@@ -583,16 +607,7 @@ function createStore(options) {
583
607
  }
584
608
  }
585
609
  }
586
- if (operation !== "read") for (const [key, value] of Object.entries(mutableState)) {
587
- if (value === void 0) continue;
588
- try {
589
- if (isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value)) continue;
590
- } catch {}
591
- issues.push({
592
- message: "value does not survive JSON serialization (cycles, NaN, Infinity, -0, sparse arrays, or undefined properties)",
593
- path: key
594
- });
595
- }
610
+ if (operation !== "read") issues.push(...jsonInstabilityIssues(mutableState));
596
611
  if (issues.length > 0) throw new CrustStoreError("VALIDATION", `Store validation failed (${operation})\n${issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")}`, {
597
612
  operation,
598
613
  issues
@@ -602,7 +617,7 @@ function createStore(options) {
602
617
  const persisted = await readJson(filePath);
603
618
  const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
604
619
  if (persisted !== void 0 && persistedObject === void 0) throw new CrustStoreError("PARSE", `Expected a JSON object in config file: ${filePath}`, { path: filePath });
605
- return normalizeStateTypes(applyFieldDefaults(persistedObject, fields, shouldPrune));
620
+ return normalizeStateTypes(applyFieldDefaults(persistedObject, fields, pruneUnknown));
606
621
  }
607
622
  async function read() {
608
623
  const document = await readRaw();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@crustjs/store",
3
- "version": "0.4.2",
4
- "description": "DX-first, typed persistence for CLI apps with config/data/state/cache separation",
3
+ "version": "0.4.4",
4
+ "description": "Typed JSON persistence for CLI config, data, state, and cache files",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "license": "MIT",
@@ -40,12 +40,12 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@crustjs/utils": "^0.2.0"
43
+ "@crustjs/utils": "^0.2.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@crustjs/config": "0.0.0",
47
- "zod": "^4.5.4",
48
- "vite-plus": "1.0.0-rc.0"
47
+ "zod": "^4.6.5",
48
+ "vite-plus": "1.0.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "typescript": "^7.0.0"
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "engines": {
59
59
  "bun": ">=1.4.0",
60
- "node": ">=22",
60
+ "node": ">=24",
61
61
  "deno": ">=2.8"
62
62
  },
63
63
  "scripts": {