@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 +1 -1
- package/dist/index.d.ts +4 -10
- package/dist/index.js +68 -58
- package/package.json +6 -7
package/README.md
CHANGED
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:
|
|
113
|
-
} ? Exclude<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
256
|
+
validateDirPath(dirPath);
|
|
257
|
+
validateSegment(name, "name");
|
|
253
258
|
return join(dirPath, `${name}.json`);
|
|
254
259
|
}
|
|
255
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
565
|
-
|
|
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:
|
|
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")
|
|
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,
|
|
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
|
-
"description": "
|
|
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.
|
|
43
|
+
"@crustjs/utils": "^0.3.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@crustjs/config": "0.0.0",
|
|
47
|
-
"zod": "^4.6.
|
|
48
|
-
"vite-plus": "1.
|
|
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",
|