@crustjs/store 0.4.3 → 0.5.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 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.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { JsonCompatible } from "@crustjs/utils/json";
2
2
  import { BaseValueType, ResolvePrimitive } from "@crustjs/utils/primitive";
3
3
  import { InferOutput, StandardSchema } from "@crustjs/utils/schema";
4
- //#region src/types.d.ts
5
4
  /** Supported type literals for store fields. */
6
5
  type ValueType = BaseValueType;
7
6
  /** Shared fields present on every store field definition. */
@@ -109,8 +108,10 @@ type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayF
109
108
  /** Record mapping field names to their definitions. */
110
109
  type FieldsDef = Record<string, FieldDef>;
111
110
  type JsonCompatibleFields<F extends FieldsDef> = { [K in keyof F]: F[K] extends {
112
- schema: StandardSchema<unknown, infer Output>;
113
- } ? Exclude<Output, undefined> extends JsonCompatible<Exclude<Output, undefined>> ? F[K] : never : F[K]; };
111
+ schema: infer S extends StandardSchema;
112
+ } ? Exclude<InferOutput<S>, undefined> extends JsonCompatible<Exclude<InferOutput<S>, undefined>> ? F[K] : F[K] & {
113
+ readonly FIX_JSON_OUTPUT: "store schema output must be JSON-compatible data (no Date, Map, bigint, or functions)";
114
+ } : F[K]; };
114
115
  /**
115
116
  * Infer the resolved type for a single field definition.
116
117
  *
@@ -350,8 +351,6 @@ interface Store<TConfig> {
350
351
  */
351
352
  reset(): Promise<void>;
352
353
  }
353
- //#endregion
354
- //#region src/errors.d.ts
355
354
  /**
356
355
  * Contextual details attached to an `IO` error.
357
356
  *
@@ -464,8 +463,6 @@ export declare class CrustStoreError<C extends StoreErrorCode = StoreErrorCode>
464
463
  */
465
464
  is<T extends StoreErrorCode>(code: T): this is CrustStoreError<T>;
466
465
  }
467
- //#endregion
468
- //#region src/path.d.ts
469
466
  /**
470
467
  * Encapsulates runtime environment values needed for path resolution.
471
468
  *
@@ -584,8 +581,6 @@ export declare function stateDir(appName: string, env?: PlatformEnv): string;
584
581
  * ```
585
582
  */
586
583
  export declare function cacheDir(appName: string, env?: PlatformEnv): string;
587
- //#endregion
588
- //#region src/store.d.ts
589
584
  /**
590
585
  * Creates a typed async config store backed by a local JSON file.
591
586
  *
@@ -623,5 +618,4 @@ export declare function cacheDir(appName: string, env?: PlatformEnv): string;
623
618
  * ```
624
619
  */
625
620
  export declare function createStore<const F extends FieldsDef>(options: CreateStoreOptions<F>): Store<InferStoreConfig<F>>;
626
- //#endregion
627
621
  export type { CreateStoreOptions, FieldDef, FieldsDef, InferStoreConfig, PlatformEnv, Store, StoreAccess, StoreErrorCode, StorePermissionBits, StoreUpdater, StoreValidatorIssue, ValidationErrorDetails, ValueType };
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { homedir } from "node:os";
2
- import { dirname, join, win32 } from "node:path";
2
+ import { dirname, join, posix, win32 } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { isJsonObject } from "@crustjs/utils/json";
5
5
  import { coerceBooleanString, tryCoerceNumber } from "@crustjs/utils/primitive";
6
6
  import { normalizeStandardIssues } from "@crustjs/utils/schema";
7
7
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
8
8
  import { isErrnoException } from "@crustjs/utils/error";
9
- //#region src/errors.ts
9
+ // src/errors.ts
10
10
  /**
11
11
  * A typed error thrown by `@crustjs/store` for path, parse, IO, and validation failures.
12
12
  *
@@ -56,8 +56,7 @@ var CrustStoreError = class extends Error {
56
56
  return Object.is(this.code, code);
57
57
  }
58
58
  };
59
- //#endregion
60
- //#region src/path.ts
59
+ // src/path.ts
61
60
  /**
62
61
  * Returns the current runtime environment for path resolution.
63
62
  *
@@ -70,13 +69,19 @@ function getRuntimeEnv() {
70
69
  homedir: homedir()
71
70
  };
72
71
  }
73
- /** Validates a path input according to its role. */
74
- function validatePath(value, field) {
72
+ function validateNonEmpty(value, field) {
75
73
  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 });
74
+ }
75
+ /** Validates a single path segment: `appName` or the store `name`. */
76
+ function validateSegment(value, field) {
77
+ validateNonEmpty(value, field);
78
+ if (value.includes("/") || value.includes("\\")) throw new CrustStoreError("PATH", `${field} must not contain path separators`, { path: value });
78
79
  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 });
80
+ }
81
+ function validateDirPath(value) {
82
+ validateNonEmpty(value, "dirPath");
83
+ if (!win32.isAbsolute(value)) throw new CrustStoreError("PATH", "dirPath must be an absolute path", { path: value });
84
+ 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
85
  }
81
86
  const DIRECTORY_CONFIG = {
82
87
  config: {
@@ -109,11 +114,11 @@ const DIRECTORY_CONFIG = {
109
114
  }
110
115
  };
111
116
  function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
112
- validatePath(appName, "appName");
117
+ validateSegment(appName, "appName");
113
118
  const config = DIRECTORY_CONFIG[kind];
114
119
  if (env.platform === "linux" || env.platform === "darwin") {
115
120
  const configured = env.env[config.xdg];
116
- const base = configured?.trim() ? configured : join(env.homedir, ...config.unixFallback);
121
+ const base = configured && posix.isAbsolute(configured) ? configured : join(env.homedir, ...config.unixFallback);
117
122
  return join(base, appName);
118
123
  }
119
124
  if (env.platform === "win32") {
@@ -248,12 +253,11 @@ function cacheDir(appName, env) {
248
253
  * @throws {CrustStoreError} `PATH` if `dirPath` or `name` is invalid.
249
254
  */
250
255
  function resolveStorePath(dirPath, name) {
251
- validatePath(dirPath, "dirPath");
252
- validatePath(name, "name");
256
+ validateDirPath(dirPath);
257
+ validateSegment(name, "name");
253
258
  return join(dirPath, `${name}.json`);
254
259
  }
255
- //#endregion
256
- //#region src/merge.ts
260
+ // src/merge.ts
257
261
  /**
258
262
  * Sets an own enumerable data property. Plain assignment would invoke the
259
263
  * inherited `__proto__` setter instead of storing a `__proto__` field.
@@ -287,14 +291,13 @@ function setDocumentValue(document, key, value) {
287
291
  function applyFieldDefaults(persisted, fields, pruneUnknown = true) {
288
292
  const result = {};
289
293
  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));
294
+ else if (def.default !== void 0) setDocumentValue(result, key, structuredClone(def.default));
291
295
  if (!pruneUnknown && persisted !== void 0) {
292
296
  for (const [key, value] of Object.entries(persisted)) if (!Object.hasOwn(fields, key)) setDocumentValue(result, key, value);
293
297
  }
294
298
  return result;
295
299
  }
296
- //#endregion
297
- //#region src/persistence.ts
300
+ // src/persistence.ts
298
301
  /**
299
302
  * Reads and parses a JSON config file from disk.
300
303
  *
@@ -312,7 +315,7 @@ async function readJson(filePath) {
312
315
  try {
313
316
  raw = await readFile(filePath, "utf-8");
314
317
  } catch (err) {
315
- if (isEnoent(err)) return;
318
+ if (isErrnoException(err) && err.code === "ENOENT") return;
316
319
  throw new CrustStoreError("IO", `Failed to read config file: ${filePath}`, {
317
320
  path: filePath,
318
321
  operation: "read"
@@ -409,14 +412,7 @@ async function deleteJson(filePath) {
409
412
  }, err);
410
413
  }
411
414
  }
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
- //#endregion
419
- //#region src/store.ts
415
+ // src/store.ts
420
416
  function makeSchemaValidator(schema) {
421
417
  return async (value) => {
422
418
  const result = await schema["~standard"].validate(value);
@@ -440,6 +436,46 @@ function matchesDeclaredType(def, value) {
440
436
  function expectedTypeMessage(def) {
441
437
  return `Expected ${def.type}${def.array === true ? "[]" : ""}`;
442
438
  }
439
+ function coerceByType(value, type) {
440
+ if (type === "number" && typeof value === "string") return tryCoerceNumber(value) ?? value;
441
+ if (type === "boolean" && typeof value === "string") return coerceBooleanString(value);
442
+ return value;
443
+ }
444
+ /**
445
+ * Re-validates a changed transform output once. This catches cross-type
446
+ * transforms like `z.string().transform(Number)` whose output would fail the
447
+ * schema on the next read.
448
+ *
449
+ * @returns The issue message when the next read would see a different value.
450
+ */
451
+ async function readUnstableTransformMessage(validator, transformed) {
452
+ let recheck;
453
+ try {
454
+ recheck = await validator(transformed);
455
+ } catch (cause) {
456
+ return `read-unstable transform: ${cause instanceof Error ? cause.message : "re-validation failed"}`;
457
+ }
458
+ if (recheck !== void 0 && (!isFieldValueResult(recheck) || !isDeepStrictEqual(recheck.value, transformed))) return "read-unstable transform: output would be transformed again on re-read";
459
+ }
460
+ /**
461
+ * Mutations promise to return exactly what the next read() sees, so any
462
+ * value JSON serialization would alter (NaN/Infinity → null, -0 → 0,
463
+ * array holes → null, dropped undefined object properties) is rejected.
464
+ */
465
+ function jsonInstabilityIssues(document) {
466
+ const issues = [];
467
+ for (const [key, value] of Object.entries(document)) {
468
+ if (value === void 0) continue;
469
+ try {
470
+ if (isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value)) continue;
471
+ } catch {}
472
+ issues.push({
473
+ message: "value does not survive JSON serialization (cycles, NaN, Infinity, -0, sparse arrays, or undefined properties)",
474
+ path: key
475
+ });
476
+ }
477
+ return issues;
478
+ }
443
479
  /**
444
480
  * Creates a typed async config store backed by a local JSON file.
445
481
  *
@@ -484,7 +520,6 @@ function createStore(options) {
484
520
  validators.set(key, makeSchemaValidator(def.schema));
485
521
  } else if (def.validate !== void 0) validators.set(key, def.validate);
486
522
  const filePath = resolveStorePath(dirPath, name);
487
- const shouldPrune = pruneUnknown ?? true;
488
523
  const writeOptions = access === "private" ? {
489
524
  fileMode: 384,
490
525
  directoryMode: 448
@@ -492,11 +527,6 @@ function createStore(options) {
492
527
  fileMode: access.file,
493
528
  directoryMode: access.directory
494
529
  };
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
530
  function normalizeStateTypes(state) {
501
531
  const normalized = { ...state };
502
532
  for (const [key, def] of Object.entries(fields)) {
@@ -561,20 +591,10 @@ function createStore(options) {
561
591
  continue;
562
592
  }
563
593
  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";
594
+ const unstable = await readUnstableTransformMessage(validator, transformed);
595
+ if (unstable !== void 0) {
569
596
  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`,
597
+ message: unstable,
578
598
  path: key
579
599
  });
580
600
  continue;
@@ -583,16 +603,7 @@ function createStore(options) {
583
603
  }
584
604
  }
585
605
  }
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
- }
606
+ if (operation !== "read") issues.push(...jsonInstabilityIssues(mutableState));
596
607
  if (issues.length > 0) throw new CrustStoreError("VALIDATION", `Store validation failed (${operation})\n${issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")}`, {
597
608
  operation,
598
609
  issues
@@ -602,7 +613,7 @@ function createStore(options) {
602
613
  const persisted = await readJson(filePath);
603
614
  const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
604
615
  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));
616
+ return normalizeStateTypes(applyFieldDefaults(persistedObject, fields, pruneUnknown));
606
617
  }
607
618
  async function read() {
608
619
  const document = await readRaw();
@@ -638,5 +649,4 @@ function createStore(options) {
638
649
  reset
639
650
  };
640
651
  }
641
- //#endregion
642
652
  export { CrustStoreError, cacheDir, configDir, createStore, dataDir, stateDir };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@crustjs/store",
3
- "version": "0.4.3",
4
- "description": "DX-first, typed persistence for CLI apps with config/data/state/cache separation",
3
+ "version": "0.5.0",
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.1"
43
+ "@crustjs/utils": "^0.3.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@crustjs/config": "0.0.0",
47
- "zod": "^4.6.5",
48
- "vite-plus": "1.0.0"
47
+ "zod": "^4.6.7",
48
+ "vite-plus": "1.4.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "typescript": "^7.0.0"
@@ -57,8 +57,7 @@
57
57
  },
58
58
  "engines": {
59
59
  "bun": ">=1.4.0",
60
- "node": ">=24",
61
- "deno": ">=2.8"
60
+ "node": ">=24"
62
61
  },
63
62
  "scripts": {
64
63
  "build": "vp run build:task",