@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 +1 -1
- package/dist/index.d.ts +18 -92
- package/dist/index.js +29 -130
- package/package.json +4 -2
package/README.md
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,84 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
*
|
|
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
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
|
391
|
-
*
|
|
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
|
-
*
|
|
409
|
-
* applied but has not undergone full field validation. The
|
|
410
|
-
* validated before persistence.
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
666
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
695
|
-
const normalized = normalizeStateTypes(
|
|
696
|
-
await runFieldValidators(normalized,
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
...await readRaw(
|
|
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
|
+
"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": {
|