@crustjs/store 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -101,7 +101,7 @@ interface FieldDefBase<V> {
101
101
  * persistence and is re-validated once to catch read-unstable
102
102
  * transforms. Outputs outside the declared type are rejected with
103
103
  * `VALIDATION`. On `read`, the transformed value is discarded —
104
- * reads always return the on-disk value verbatim.
104
+ * core fields return their defaulted, type-coerced input value.
105
105
  * - Throwing an error — the value is rejected; the error message is
106
106
  * captured as a validation issue with the field name as `path`.
107
107
  *
@@ -315,11 +315,11 @@ interface CreateStoreOptions<F extends FieldsDef> {
315
315
  access?: StoreAccess;
316
316
  }
317
317
  /**
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.
318
+ * Receives the validated current config, including defaults, core coercion,
319
+ * and schema output, and returns an updated config. Invalid current state
320
+ * rejects before this callback runs; use `write` or `patch` to initialize or repair it.
321
321
  *
322
- * Used by {@link Store.update}, which validates the result before persistence.
322
+ * Used by {@link Store.update}, which also validates the result before persistence.
323
323
  *
324
324
  * @example
325
325
  * ```ts
@@ -360,7 +360,8 @@ type StoreUpdater<TConfig> = (current: TConfig) => NoInfer<TConfig>;
360
360
  */
361
361
  interface Store<TConfig> {
362
362
  /**
363
- * Reads the persisted config, applying field defaults for missing keys.
363
+ * Reads the persisted config, applying field defaults for missing keys and
364
+ * returning successful schema output (including transforms) without writing.
364
365
  *
365
366
  * Always returns a value — fields with defaults are guaranteed present,
366
367
  * fields without defaults may be `undefined`.
@@ -387,8 +388,9 @@ interface Store<TConfig> {
387
388
  */
388
389
  write(config: NoInfer<TConfig>): Promise<TConfig>;
389
390
  /**
390
- * Reads current config with defaults and core coercion applied, without
391
- * full field validation. Applies the updater, validates the result, and persists.
391
+ * Reads and validates current config before calling the updater. Applies
392
+ * the updater, validates the result, and persists. Invalid current state
393
+ * rejects before the callback runs; use `write` or `patch` to initialize or repair.
392
394
  *
393
395
  * The final file replacement is atomic, but the read-modify-write sequence
394
396
  * is not serialized, even within one process. Await mutations sequentially
@@ -405,9 +407,11 @@ interface Store<TConfig> {
405
407
  * Applies a partial update to the current config and persists.
406
408
  *
407
409
  * 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.
410
+ * Unlike {@link Store.update}, the current config has core defaults and coercion
411
+ * applied but has not undergone full field validation, allowing repairs. The
412
+ * merged result is validated before persistence. Explicit `undefined` is rejected
413
+ * for defaulted core fields; optional core fields and schema-owned optionality
414
+ * remain supported. Await mutations sequentially to avoid lost updates.
411
415
  *
412
416
  * @param partial - A partial subset of the config to merge in.
413
417
  * @returns The persisted config, including schema transformations.
package/dist/index.js CHANGED
@@ -602,7 +602,13 @@ function createStore(options) {
602
602
  const issues = [];
603
603
  for (const [key, def] of Object.entries(fields)) {
604
604
  const value = mutableState[key];
605
- if (value === void 0 && def.schema === void 0) continue;
605
+ if (value === void 0 && def.schema === void 0) {
606
+ if (def.default !== void 0) issues.push({
607
+ message: expectedTypeMessage(def),
608
+ path: key
609
+ });
610
+ continue;
611
+ }
606
612
  if (value !== void 0 && def.schema === void 0 && !matchesDeclaredType(def, value)) {
607
613
  issues.push({
608
614
  message: expectedTypeMessage(def),
@@ -627,7 +633,7 @@ function createStore(options) {
627
633
  if (isFieldValueResult(result)) {
628
634
  const transformed = result.value;
629
635
  if (operation === "read") {
630
- if (value === void 0) mutableState[key] = transformed;
636
+ if (def.schema !== void 0) mutableState[key] = transformed;
631
637
  continue;
632
638
  }
633
639
  if (def.schema === void 0 && !matchesDeclaredType(def, transformed)) {
@@ -662,8 +668,11 @@ function createStore(options) {
662
668
  }
663
669
  if (operation !== "read") for (const [key, value] of Object.entries(mutableState)) {
664
670
  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)",
671
+ try {
672
+ if (isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value)) continue;
673
+ } catch {}
674
+ issues.push({
675
+ message: "value does not survive JSON serialization (cycles, NaN, Infinity, -0, sparse arrays, or undefined properties)",
667
676
  path: key
668
677
  });
669
678
  }
@@ -672,45 +681,34 @@ function createStore(options) {
672
681
  issues
673
682
  });
674
683
  }
675
- async function readRaw(materializeSchemaDefaults = false) {
684
+ async function readRaw() {
676
685
  const persisted = await readJson(filePath);
677
686
  const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
678
687
  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);
688
+ return normalizeStateTypes(applyFieldDefaults(persistedObject, fields, shouldPrune));
688
689
  }
689
690
  async function read() {
690
691
  const document = await readRaw();
691
692
  await runFieldValidators(document, "read");
692
693
  return document;
693
694
  }
694
- async function write(config) {
695
- const normalized = normalizeStateTypes({ ...config });
696
- await runFieldValidators(normalized, "write");
695
+ async function persist(document, operation) {
696
+ const normalized = normalizeStateTypes(document);
697
+ await runFieldValidators(normalized, operation);
697
698
  await writeJson(filePath, normalized, writeOptions);
698
699
  return normalized;
699
700
  }
701
+ async function write(config) {
702
+ return persist({ ...config }, "write");
703
+ }
700
704
  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;
705
+ return persist({ ...updater(await read()) }, "update");
705
706
  }
706
707
  async function patch(partial) {
707
- const normalized = normalizeStateTypes({
708
- ...await readRaw(true),
708
+ return persist({
709
+ ...await readRaw(),
709
710
  ...partial
710
- });
711
- await runFieldValidators(normalized, "patch");
712
- await writeJson(filePath, normalized, writeOptions);
713
- return normalized;
711
+ }, "patch");
714
712
  }
715
713
  async function reset() {
716
714
  await deleteJson(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/store",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "DX-first, typed persistence for CLI apps with config/data/state/cache separation",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -62,7 +62,7 @@
62
62
  }
63
63
  },
64
64
  "engines": {
65
- "bun": ">=1.3.14",
65
+ "bun": ">=1.4.0",
66
66
  "node": ">=22",
67
67
  "deno": ">=2.8"
68
68
  }