@crustjs/store 0.3.1 → 0.4.1

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
@@ -5,7 +5,7 @@ DX-first, typed persistence for CLI apps with config/data/state/cache separation
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- bun add @crustjs/store
8
+ npm install @crustjs/store
9
9
  ```
10
10
 
11
11
  ## Documentation
package/dist/index.d.ts CHANGED
@@ -1,84 +1,6 @@
1
- //#region ../utils/src/json.d.ts
2
- /** Any value representable in a JSON document. */
3
- type JsonValue = string | number | boolean | null | readonly JsonValue[] | JsonObject;
4
- interface JsonObject {
5
- [key: string]: JsonValue;
6
- }
7
- /** Preserve a type when all of its properties are recursively JSON-compatible. */
8
- type JsonCompatible<T> = T extends JsonValue ? T : T extends ((...arguments_: never[]) => void) ? never : T extends readonly unknown[] ? { [K in keyof T]: JsonCompatible<T[K]>; } : T extends object ? { [K in keyof T]: JsonCompatible<T[K]>; } : never;
9
- //#endregion
10
- //#region ../utils/src/primitive.d.ts
11
- /** Supported primitive type literals shared by Crust packages. */
12
- type BaseValueType = "string" | "number" | "boolean";
13
- /**
14
- * Resolves a primitive type literal to its corresponding TypeScript type.
15
- *
16
- * @example
17
- * ```ts
18
- * type Port = ResolvePrimitive<"number">;
19
- * // ^? number
20
- * ```
21
- */
22
- type ResolvePrimitive<T extends BaseValueType> = T extends "string" ? string : T extends "number" ? number : T extends "boolean" ? boolean : never;
23
- //#endregion
24
- //#region ../utils/src/schema.d.ts
25
- /** The Standard Typed types interface. */
26
- interface StandardSchemaTypes<Input = unknown, Output = Input> {
27
- /** The input type of the schema. */
28
- readonly input: Input;
29
- /** The output type of the schema. */
30
- readonly output: Output;
31
- }
32
- /** The result interface if validation succeeds. */
33
- interface StandardSchemaSuccessResult<Output> {
34
- /** The typed output value. */
35
- readonly value: Output;
36
- /** A falsy value for `issues` indicates success. */
37
- readonly issues?: undefined;
38
- }
39
- /** The result interface if validation fails. */
40
- interface StandardSchemaFailureResult {
41
- /** The issues of failed validation. */
42
- readonly issues: ReadonlyArray<StandardSchemaIssue>;
43
- }
44
- /** The result interface of the validate function. */
45
- type StandardSchemaResult<Output> = StandardSchemaSuccessResult<Output> | StandardSchemaFailureResult;
46
- /** The issue interface of the failure output. */
47
- interface StandardSchemaIssue {
48
- /** The error message of the issue. */
49
- readonly message: string;
50
- /** The path of the issue, if any. */
51
- readonly path?: ReadonlyArray<PropertyKey | StandardSchemaPathSegment> | undefined;
52
- }
53
- /** The path segment interface of the issue. */
54
- interface StandardSchemaPathSegment {
55
- /** The key representing a path segment. */
56
- readonly key: PropertyKey;
57
- }
58
- /** The Standard Schema properties interface. */
59
- interface StandardSchemaProps<Input = unknown, Output = Input> {
60
- /** The version number of the standard. */
61
- readonly version: 1;
62
- /** The vendor name of the schema library. */
63
- readonly vendor: string;
64
- /** Inferred types associated with the schema. */
65
- readonly types?: StandardSchemaTypes<Input, Output> | undefined;
66
- /** Validates unknown input values. */
67
- readonly validate: (value: StandardSchemaTypes["input"]) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
68
- }
69
- /**
70
- * A Standard Schema-compatible schema object.
71
- *
72
- * Any schema library implementing the Standard Schema v1 spec
73
- * (Zod, Effect, Valibot, ArkType, etc.) produces objects matching this type.
74
- */
75
- type StandardSchema<Input = unknown, Output = Input> = {
76
- /** The Standard Schema properties. */
77
- readonly "~standard": StandardSchemaProps<Input, Output>;
78
- };
79
- /** Infer the output type produced by a Standard Schema on success. */
80
- type InferOutput<S extends StandardSchema> = NonNullable<S["~standard"]["types"]>["output"];
81
- //#endregion
1
+ import { JsonCompatible } from "@crustjs/utils/json";
2
+ import { BaseValueType, ResolvePrimitive } from "@crustjs/utils/primitive";
3
+ import { InferOutput, StandardSchema } from "@crustjs/utils/schema";
82
4
  //#region src/types.d.ts
83
5
  /** Supported type literals for store fields. */
84
6
  type ValueType = BaseValueType;
@@ -101,7 +23,7 @@ interface FieldDefBase<V> {
101
23
  * persistence and is re-validated once to catch read-unstable
102
24
  * transforms. Outputs outside the declared type are rejected with
103
25
  * `VALIDATION`. On `read`, the transformed value is discarded —
104
- * reads always return the on-disk value verbatim.
26
+ * core fields return their defaulted, type-coerced input value.
105
27
  * - Throwing an error — the value is rejected; the error message is
106
28
  * captured as a validation issue with the field name as `path`.
107
29
  *
@@ -315,11 +237,11 @@ interface CreateStoreOptions<F extends FieldsDef> {
315
237
  access?: StoreAccess;
316
238
  }
317
239
  /**
318
- * Receives the current config with defaults and core coercion applied,
319
- * but without full field validation, and returns an updated config.
320
- * Persisted values may not yet satisfy their declared types or validators.
240
+ * Receives the validated current config, including defaults, core coercion,
241
+ * and schema output, and returns an updated config. Invalid current state
242
+ * rejects before this callback runs; use `write` or `patch` to initialize or repair it.
321
243
  *
322
- * Used by {@link Store.update}, which validates the result before persistence.
244
+ * Used by {@link Store.update}, which also validates the result before persistence.
323
245
  *
324
246
  * @example
325
247
  * ```ts
@@ -360,7 +282,8 @@ type StoreUpdater<TConfig> = (current: TConfig) => NoInfer<TConfig>;
360
282
  */
361
283
  interface Store<TConfig> {
362
284
  /**
363
- * Reads the persisted config, applying field defaults for missing keys.
285
+ * Reads the persisted config, applying field defaults for missing keys and
286
+ * returning successful schema output (including transforms) without writing.
364
287
  *
365
288
  * Always returns a value — fields with defaults are guaranteed present,
366
289
  * fields without defaults may be `undefined`.
@@ -387,8 +310,9 @@ interface Store<TConfig> {
387
310
  */
388
311
  write(config: NoInfer<TConfig>): Promise<TConfig>;
389
312
  /**
390
- * Reads current config with defaults and core coercion applied, without
391
- * full field validation. Applies the updater, validates the result, and persists.
313
+ * Reads and validates current config before calling the updater. Applies
314
+ * the updater, validates the result, and persists. Invalid current state
315
+ * rejects before the callback runs; use `write` or `patch` to initialize or repair.
392
316
  *
393
317
  * The final file replacement is atomic, but the read-modify-write sequence
394
318
  * is not serialized, even within one process. Await mutations sequentially
@@ -405,9 +329,11 @@ interface Store<TConfig> {
405
329
  * Applies a partial update to the current config and persists.
406
330
  *
407
331
  * Only the provided keys are updated; everything else is preserved.
408
- * Like {@link Store.update}, the current config has defaults and core coercion
409
- * applied but has not undergone full field validation. The merged result is
410
- * validated before persistence. Await mutations sequentially to avoid lost updates.
332
+ * Unlike {@link Store.update}, the current config has core defaults and coercion
333
+ * applied but has not undergone full field validation, allowing repairs. The
334
+ * merged result is validated before persistence. Explicit `undefined` is rejected
335
+ * for defaulted core fields; optional core fields and schema-owned optionality
336
+ * remain supported. Await mutations sequentially to avoid lost updates.
411
337
  *
412
338
  * @param partial - A partial subset of the config to merge in.
413
339
  * @returns The persisted config, including schema transformations.
package/dist/index.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join, win32 } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
+ import { isJsonObject } from "@crustjs/utils/json";
5
+ import { coerceBooleanString, tryCoerceNumber } from "@crustjs/utils/primitive";
6
+ import { normalizeStandardIssues } from "@crustjs/utils/schema";
4
7
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
8
+ import { isErrnoException } from "@crustjs/utils/error";
5
9
  //#region src/errors.ts
6
10
  /**
7
11
  * A typed error thrown by `@crustjs/store` for path, parse, IO, and validation failures.
@@ -249,103 +253,6 @@ function resolveStorePath(dirPath, name) {
249
253
  return join(dirPath, `${name}.json`);
250
254
  }
251
255
  //#endregion
252
- //#region ../utils/src/json.ts
253
- /** Narrow a JSON document root to an object (not array/scalar/null). */
254
- function isJsonObject(value) {
255
- return typeof value === "object" && value !== null && !Array.isArray(value);
256
- }
257
- //#endregion
258
- //#region ../utils/src/primitive.ts
259
- /**
260
- * Attempts to coerce a string to a number, returning `undefined` only when the
261
- * result is `NaN`. Callers decide whether `undefined` means throw or fallback.
262
- *
263
- * @example
264
- * ```ts
265
- * tryCoerceNumber("42"); // 42
266
- * tryCoerceNumber("abc"); // undefined
267
- * tryCoerceNumber(""); // 0
268
- * ```
269
- */
270
- function tryCoerceNumber(raw) {
271
- const num = Number(raw);
272
- return Number.isNaN(num) ? void 0 : num;
273
- }
274
- /**
275
- * Coerces Crust boolean strings using the existing strict truthy spellings.
276
- *
277
- * @example
278
- * ```ts
279
- * coerceBooleanString("true"); // true
280
- * coerceBooleanString("1"); // true
281
- * coerceBooleanString("false"); // false
282
- * ```
283
- */
284
- function coerceBooleanString(raw) {
285
- return raw === "true" || raw === "1";
286
- }
287
- //#endregion
288
- //#region ../utils/src/schema.ts
289
- /**
290
- * Format an issue path into a dot-path string.
291
- *
292
- * - Numeric keys (array indexes) are rendered with bracket notation: `items[0]`
293
- * - String/symbol keys are joined with dots: `flags.verbose`
294
- * - An empty path array produces an empty string (root-level issue)
295
- *
296
- * @example
297
- * ```ts
298
- * formatPath(["flags", "verbose"]);
299
- * // => "flags.verbose"
300
- *
301
- * formatPath(["args", 0]);
302
- * // => "args[0]"
303
- *
304
- * formatPath([]);
305
- * // => ""
306
- * ```
307
- */
308
- function isNumericPathSegment(segment) {
309
- return typeof segment === "number";
310
- }
311
- function formatPath(path) {
312
- return path.map((segment, index) => isNumericPathSegment(segment) ? `[${segment}]` : index > 0 ? `.${String(segment)}` : String(segment)).join("");
313
- }
314
- /**
315
- * Normalize a Standard Schema issue path to an array of `PropertyKey`.
316
- *
317
- * Standard Schema paths contain either bare `PropertyKey` values or
318
- * `{ key: PropertyKey }` segment objects; both forms are flattened to
319
- * plain `PropertyKey`. Returns an empty array for a root-level issue
320
- * (`undefined`).
321
- */
322
- function isStandardPathSegment(segment) {
323
- return typeof segment === "object" && segment !== null && "key" in segment;
324
- }
325
- function normalizeStandardPath(path) {
326
- if (!path) return [];
327
- return path.map((segment) => isStandardPathSegment(segment) ? segment.key : segment);
328
- }
329
- /**
330
- * Normalize Standard Schema issues into canonical `ValidationIssue` objects.
331
- *
332
- * Applies an optional prefix (e.g. `["flags", "verbose"]`) to each issue
333
- * path, then formats each path to its canonical dot-path string.
334
- *
335
- * @param issues — Raw Standard Schema issues from a failed validation
336
- * @param prefix — Optional path segments prepended to each issue path
337
- */
338
- function normalizeStandardIssues(issues, prefix = []) {
339
- return issues.map((issue) => {
340
- const resolvedPath = normalizeStandardPath(issue.path);
341
- const fullPath = [...prefix, ...resolvedPath];
342
- return {
343
- message: issue.message,
344
- path: formatPath(fullPath)
345
- };
346
- });
347
- }
348
- //#endregion
349
256
  //#region src/merge.ts
350
257
  /**
351
258
  * Applies field defaults to a persisted config object.
@@ -375,12 +282,6 @@ function applyFieldDefaults(persisted, fields, pruneUnknown = true) {
375
282
  return result;
376
283
  }
377
284
  //#endregion
378
- //#region ../utils/src/error.ts
379
- /** Narrows a caught Node.js system error to its stable errno contract. */
380
- function isErrnoException(error) {
381
- return error instanceof Error && "code" in error && typeof error.code === "string";
382
- }
383
- //#endregion
384
285
  //#region src/persistence.ts
385
286
  /**
386
287
  * Reads and parses a JSON config file from disk.
@@ -602,7 +503,13 @@ function createStore(options) {
602
503
  const issues = [];
603
504
  for (const [key, def] of Object.entries(fields)) {
604
505
  const value = mutableState[key];
605
- if (value === void 0 && def.schema === void 0) continue;
506
+ if (value === void 0 && def.schema === void 0) {
507
+ if (def.default !== void 0) issues.push({
508
+ message: expectedTypeMessage(def),
509
+ path: key
510
+ });
511
+ continue;
512
+ }
606
513
  if (value !== void 0 && def.schema === void 0 && !matchesDeclaredType(def, value)) {
607
514
  issues.push({
608
515
  message: expectedTypeMessage(def),
@@ -627,7 +534,7 @@ function createStore(options) {
627
534
  if (isFieldValueResult(result)) {
628
535
  const transformed = result.value;
629
536
  if (operation === "read") {
630
- if (value === void 0) mutableState[key] = transformed;
537
+ if (def.schema !== void 0) mutableState[key] = transformed;
631
538
  continue;
632
539
  }
633
540
  if (def.schema === void 0 && !matchesDeclaredType(def, transformed)) {
@@ -662,8 +569,11 @@ function createStore(options) {
662
569
  }
663
570
  if (operation !== "read") for (const [key, value] of Object.entries(mutableState)) {
664
571
  if (value === void 0) continue;
665
- if (!isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value)) issues.push({
666
- message: "value does not survive JSON serialization (NaN, Infinity, -0, sparse arrays, or undefined properties)",
572
+ try {
573
+ if (isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value)) continue;
574
+ } catch {}
575
+ issues.push({
576
+ message: "value does not survive JSON serialization (cycles, NaN, Infinity, -0, sparse arrays, or undefined properties)",
667
577
  path: key
668
578
  });
669
579
  }
@@ -672,45 +582,34 @@ function createStore(options) {
672
582
  issues
673
583
  });
674
584
  }
675
- async function readRaw(materializeSchemaDefaults = false) {
585
+ async function readRaw() {
676
586
  const persisted = await readJson(filePath);
677
587
  const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
678
588
  if (persisted !== void 0 && persistedObject === void 0) throw new CrustStoreError("PARSE", `Expected a JSON object in config file: ${filePath}`, { path: filePath });
679
- const merged = applyFieldDefaults(persistedObject, fields, shouldPrune);
680
- if (materializeSchemaDefaults) for (const [key, def] of Object.entries(fields)) {
681
- if (merged[key] !== void 0 || def.schema === void 0) continue;
682
- try {
683
- const result = await validators.get(key)?.(void 0);
684
- if (result !== void 0 && isFieldValueResult(result)) merged[key] = result.value;
685
- } catch {}
686
- }
687
- return normalizeStateTypes(merged);
589
+ return normalizeStateTypes(applyFieldDefaults(persistedObject, fields, shouldPrune));
688
590
  }
689
591
  async function read() {
690
592
  const document = await readRaw();
691
593
  await runFieldValidators(document, "read");
692
594
  return document;
693
595
  }
694
- async function write(config) {
695
- const normalized = normalizeStateTypes({ ...config });
696
- await runFieldValidators(normalized, "write");
596
+ async function persist(document, operation) {
597
+ const normalized = normalizeStateTypes(document);
598
+ await runFieldValidators(normalized, operation);
697
599
  await writeJson(filePath, normalized, writeOptions);
698
600
  return normalized;
699
601
  }
602
+ async function write(config) {
603
+ return persist({ ...config }, "write");
604
+ }
700
605
  async function update(updater) {
701
- const normalized = normalizeStateTypes({ ...updater(await readRaw(true)) });
702
- await runFieldValidators(normalized, "update");
703
- await writeJson(filePath, normalized, writeOptions);
704
- return normalized;
606
+ return persist({ ...updater(await read()) }, "update");
705
607
  }
706
608
  async function patch(partial) {
707
- const normalized = normalizeStateTypes({
708
- ...await readRaw(true),
609
+ return persist({
610
+ ...await readRaw(),
709
611
  ...partial
710
- });
711
- await runFieldValidators(normalized, "patch");
712
- await writeJson(filePath, normalized, writeOptions);
713
- return normalized;
612
+ }, "patch");
714
613
  }
715
614
  async function reset() {
716
615
  await deleteJson(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/store",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "DX-first, typed persistence for CLI apps with config/data/state/cache separation",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -47,10 +47,12 @@
47
47
  "prepack": "cp ../../LICENSE LICENSE",
48
48
  "postpack": "rm -f LICENSE"
49
49
  },
50
+ "dependencies": {
51
+ "@crustjs/utils": "^0.1.0"
52
+ },
50
53
  "devDependencies": {
51
54
  "@crustjs/config": "0.0.0",
52
55
  "zod": "^4.5.4",
53
- "@crustjs/utils": "0.0.0",
54
56
  "tsdown": "^0.23.0"
55
57
  },
56
58
  "peerDependencies": {