@crustjs/store 0.4.3 → 0.4.4
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.js +61 -46
- package/package.json +3 -3
package/README.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -70,13 +70,19 @@ function getRuntimeEnv() {
|
|
|
70
70
|
homedir: homedir()
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
function validatePath(value, field) {
|
|
73
|
+
function validateNonEmpty(value, field) {
|
|
75
74
|
if (!value.trim()) throw new CrustStoreError("PATH", `${field} must be a non-empty string`, { path: value });
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
}
|
|
76
|
+
/** Validates a single path segment: `appName` or the store `name`. */
|
|
77
|
+
function validateSegment(value, field) {
|
|
78
|
+
validateNonEmpty(value, field);
|
|
79
|
+
if (value.includes("/") || value.includes("\\")) throw new CrustStoreError("PATH", `${field} must not contain path separators`, { path: value });
|
|
78
80
|
if (field === "name" && value.endsWith(".json")) throw new CrustStoreError("PATH", "name must not include the .json extension", { path: value });
|
|
79
|
-
|
|
81
|
+
}
|
|
82
|
+
function validateDirPath(value) {
|
|
83
|
+
validateNonEmpty(value, "dirPath");
|
|
84
|
+
if (!win32.isAbsolute(value)) throw new CrustStoreError("PATH", "dirPath must be an absolute path", { path: value });
|
|
85
|
+
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
86
|
}
|
|
81
87
|
const DIRECTORY_CONFIG = {
|
|
82
88
|
config: {
|
|
@@ -109,7 +115,7 @@ const DIRECTORY_CONFIG = {
|
|
|
109
115
|
}
|
|
110
116
|
};
|
|
111
117
|
function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
|
|
112
|
-
|
|
118
|
+
validateSegment(appName, "appName");
|
|
113
119
|
const config = DIRECTORY_CONFIG[kind];
|
|
114
120
|
if (env.platform === "linux" || env.platform === "darwin") {
|
|
115
121
|
const configured = env.env[config.xdg];
|
|
@@ -248,8 +254,8 @@ function cacheDir(appName, env) {
|
|
|
248
254
|
* @throws {CrustStoreError} `PATH` if `dirPath` or `name` is invalid.
|
|
249
255
|
*/
|
|
250
256
|
function resolveStorePath(dirPath, name) {
|
|
251
|
-
|
|
252
|
-
|
|
257
|
+
validateDirPath(dirPath);
|
|
258
|
+
validateSegment(name, "name");
|
|
253
259
|
return join(dirPath, `${name}.json`);
|
|
254
260
|
}
|
|
255
261
|
//#endregion
|
|
@@ -287,7 +293,7 @@ function setDocumentValue(document, key, value) {
|
|
|
287
293
|
function applyFieldDefaults(persisted, fields, pruneUnknown = true) {
|
|
288
294
|
const result = {};
|
|
289
295
|
for (const [key, def] of Object.entries(fields)) if (persisted && Object.hasOwn(persisted, key)) setDocumentValue(result, key, persisted[key]);
|
|
290
|
-
else if (
|
|
296
|
+
else if (def.default !== void 0) setDocumentValue(result, key, structuredClone(def.default));
|
|
291
297
|
if (!pruneUnknown && persisted !== void 0) {
|
|
292
298
|
for (const [key, value] of Object.entries(persisted)) if (!Object.hasOwn(fields, key)) setDocumentValue(result, key, value);
|
|
293
299
|
}
|
|
@@ -312,7 +318,7 @@ async function readJson(filePath) {
|
|
|
312
318
|
try {
|
|
313
319
|
raw = await readFile(filePath, "utf-8");
|
|
314
320
|
} catch (err) {
|
|
315
|
-
if (
|
|
321
|
+
if (isErrnoException(err) && err.code === "ENOENT") return;
|
|
316
322
|
throw new CrustStoreError("IO", `Failed to read config file: ${filePath}`, {
|
|
317
323
|
path: filePath,
|
|
318
324
|
operation: "read"
|
|
@@ -409,12 +415,6 @@ async function deleteJson(filePath) {
|
|
|
409
415
|
}, err);
|
|
410
416
|
}
|
|
411
417
|
}
|
|
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
418
|
//#endregion
|
|
419
419
|
//#region src/store.ts
|
|
420
420
|
function makeSchemaValidator(schema) {
|
|
@@ -440,6 +440,46 @@ function matchesDeclaredType(def, value) {
|
|
|
440
440
|
function expectedTypeMessage(def) {
|
|
441
441
|
return `Expected ${def.type}${def.array === true ? "[]" : ""}`;
|
|
442
442
|
}
|
|
443
|
+
function coerceByType(value, type) {
|
|
444
|
+
if (type === "number" && typeof value === "string") return tryCoerceNumber(value) ?? value;
|
|
445
|
+
if (type === "boolean" && typeof value === "string") return coerceBooleanString(value);
|
|
446
|
+
return value;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Re-validates a changed transform output once. This catches cross-type
|
|
450
|
+
* transforms like `z.string().transform(Number)` whose output would fail the
|
|
451
|
+
* schema on the next read.
|
|
452
|
+
*
|
|
453
|
+
* @returns The issue message when the next read would see a different value.
|
|
454
|
+
*/
|
|
455
|
+
async function readUnstableTransformMessage(validator, transformed) {
|
|
456
|
+
let recheck;
|
|
457
|
+
try {
|
|
458
|
+
recheck = await validator(transformed);
|
|
459
|
+
} catch (cause) {
|
|
460
|
+
return `read-unstable transform: ${cause instanceof Error ? cause.message : "re-validation failed"}`;
|
|
461
|
+
}
|
|
462
|
+
if (recheck !== void 0 && (!isFieldValueResult(recheck) || !isDeepStrictEqual(recheck.value, transformed))) return "read-unstable transform: output would be transformed again on re-read";
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Mutations promise to return exactly what the next read() sees, so any
|
|
466
|
+
* value JSON serialization would alter (NaN/Infinity → null, -0 → 0,
|
|
467
|
+
* array holes → null, dropped undefined object properties) is rejected.
|
|
468
|
+
*/
|
|
469
|
+
function jsonInstabilityIssues(document) {
|
|
470
|
+
const issues = [];
|
|
471
|
+
for (const [key, value] of Object.entries(document)) {
|
|
472
|
+
if (value === void 0) continue;
|
|
473
|
+
try {
|
|
474
|
+
if (isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value)) continue;
|
|
475
|
+
} catch {}
|
|
476
|
+
issues.push({
|
|
477
|
+
message: "value does not survive JSON serialization (cycles, NaN, Infinity, -0, sparse arrays, or undefined properties)",
|
|
478
|
+
path: key
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return issues;
|
|
482
|
+
}
|
|
443
483
|
/**
|
|
444
484
|
* Creates a typed async config store backed by a local JSON file.
|
|
445
485
|
*
|
|
@@ -484,7 +524,6 @@ function createStore(options) {
|
|
|
484
524
|
validators.set(key, makeSchemaValidator(def.schema));
|
|
485
525
|
} else if (def.validate !== void 0) validators.set(key, def.validate);
|
|
486
526
|
const filePath = resolveStorePath(dirPath, name);
|
|
487
|
-
const shouldPrune = pruneUnknown ?? true;
|
|
488
527
|
const writeOptions = access === "private" ? {
|
|
489
528
|
fileMode: 384,
|
|
490
529
|
directoryMode: 448
|
|
@@ -492,11 +531,6 @@ function createStore(options) {
|
|
|
492
531
|
fileMode: access.file,
|
|
493
532
|
directoryMode: access.directory
|
|
494
533
|
};
|
|
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
534
|
function normalizeStateTypes(state) {
|
|
501
535
|
const normalized = { ...state };
|
|
502
536
|
for (const [key, def] of Object.entries(fields)) {
|
|
@@ -561,20 +595,10 @@ function createStore(options) {
|
|
|
561
595
|
continue;
|
|
562
596
|
}
|
|
563
597
|
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";
|
|
598
|
+
const unstable = await readUnstableTransformMessage(validator, transformed);
|
|
599
|
+
if (unstable !== void 0) {
|
|
569
600
|
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`,
|
|
601
|
+
message: unstable,
|
|
578
602
|
path: key
|
|
579
603
|
});
|
|
580
604
|
continue;
|
|
@@ -583,16 +607,7 @@ function createStore(options) {
|
|
|
583
607
|
}
|
|
584
608
|
}
|
|
585
609
|
}
|
|
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
|
-
}
|
|
610
|
+
if (operation !== "read") issues.push(...jsonInstabilityIssues(mutableState));
|
|
596
611
|
if (issues.length > 0) throw new CrustStoreError("VALIDATION", `Store validation failed (${operation})\n${issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")}`, {
|
|
597
612
|
operation,
|
|
598
613
|
issues
|
|
@@ -602,7 +617,7 @@ function createStore(options) {
|
|
|
602
617
|
const persisted = await readJson(filePath);
|
|
603
618
|
const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
|
|
604
619
|
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,
|
|
620
|
+
return normalizeStateTypes(applyFieldDefaults(persistedObject, fields, pruneUnknown));
|
|
606
621
|
}
|
|
607
622
|
async function read() {
|
|
608
623
|
const document = await readRaw();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crustjs/store",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.4",
|
|
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,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@crustjs/utils": "^0.2.
|
|
43
|
+
"@crustjs/utils": "^0.2.2"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@crustjs/config": "0.0.0",
|