@crustjs/store 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +3 -604
- package/dist/index.d.ts +624 -717
- package/dist/index.js +727 -4
- package/package.json +21 -13
package/dist/index.js
CHANGED
|
@@ -1,4 +1,727 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join, win32 } from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
//#region src/errors.ts
|
|
6
|
+
/**
|
|
7
|
+
* A typed error thrown by `@crustjs/store` for path, parse, IO, and validation failures.
|
|
8
|
+
*
|
|
9
|
+
* Every `CrustStoreError` carries a {@link StoreErrorCode} that identifies the failure
|
|
10
|
+
* category. Codes other than `DEFINITION` include structured {@link StoreErrorDetails}
|
|
11
|
+
* for programmatic handling without fragile message parsing.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { CrustStoreError } from "@crustjs/store";
|
|
16
|
+
*
|
|
17
|
+
* try {
|
|
18
|
+
* const config = await store.read();
|
|
19
|
+
* } catch (err) {
|
|
20
|
+
* if (err instanceof CrustStoreError && err.is("PARSE")) {
|
|
21
|
+
* console.error(`Corrupt config at ${err.details.path}`);
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
var CrustStoreError = class extends Error {
|
|
27
|
+
/** Machine-readable error code for programmatic handling. */
|
|
28
|
+
code;
|
|
29
|
+
/** Structured payload with context about the failure. */
|
|
30
|
+
details;
|
|
31
|
+
constructor(code, message, ...[details, cause]) {
|
|
32
|
+
super(message, { cause });
|
|
33
|
+
this.name = "CrustStoreError";
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.details = details;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Type-guard that narrows this error to a specific {@link StoreErrorCode}.
|
|
39
|
+
*
|
|
40
|
+
* When the guard returns `true`, TypeScript narrows the `details` property
|
|
41
|
+
* to the corresponding details type.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* if (err.is("IO")) {
|
|
46
|
+
* // err.details is IOErrorDetails
|
|
47
|
+
* console.error(err.details.operation, err.details.path);
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
is(code) {
|
|
52
|
+
return Object.is(this.code, code);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/path.ts
|
|
57
|
+
/**
|
|
58
|
+
* Returns the current runtime environment for path resolution.
|
|
59
|
+
*
|
|
60
|
+
* @returns Platform environment values sourced from `process` and `os.homedir()`.
|
|
61
|
+
*/
|
|
62
|
+
function getRuntimeEnv() {
|
|
63
|
+
return {
|
|
64
|
+
platform: process.platform,
|
|
65
|
+
env: process.env,
|
|
66
|
+
homedir: homedir()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Validates a path input according to its role. */
|
|
70
|
+
function validatePath(value, field) {
|
|
71
|
+
if (!value.trim()) throw new CrustStoreError("PATH", `${field} must be a non-empty string`, { path: value });
|
|
72
|
+
if (field !== "dirPath" && (value.includes("/") || value.includes("\\"))) throw new CrustStoreError("PATH", `${field} must not contain path separators`, { path: value });
|
|
73
|
+
if (field === "dirPath" && !win32.isAbsolute(value)) throw new CrustStoreError("PATH", "dirPath must be an absolute path", { path: value });
|
|
74
|
+
if (field === "name" && value.endsWith(".json")) throw new CrustStoreError("PATH", "name must not include the .json extension", { path: value });
|
|
75
|
+
if (field === "dirPath" && value.endsWith(".json")) throw new CrustStoreError("PATH", "dirPath must be a directory path, not a file path (should not end with .json)", { path: value });
|
|
76
|
+
}
|
|
77
|
+
const DIRECTORY_CONFIG = {
|
|
78
|
+
config: {
|
|
79
|
+
xdg: "XDG_CONFIG_HOME",
|
|
80
|
+
unixFallback: [".config"],
|
|
81
|
+
windowsEnv: "APPDATA",
|
|
82
|
+
windowsFallback: ["AppData", "Roaming"],
|
|
83
|
+
windowsSuffix: []
|
|
84
|
+
},
|
|
85
|
+
data: {
|
|
86
|
+
xdg: "XDG_DATA_HOME",
|
|
87
|
+
unixFallback: [".local", "share"],
|
|
88
|
+
windowsEnv: "LOCALAPPDATA",
|
|
89
|
+
windowsFallback: ["AppData", "Local"],
|
|
90
|
+
windowsSuffix: ["Data"]
|
|
91
|
+
},
|
|
92
|
+
state: {
|
|
93
|
+
xdg: "XDG_STATE_HOME",
|
|
94
|
+
unixFallback: [".local", "state"],
|
|
95
|
+
windowsEnv: "LOCALAPPDATA",
|
|
96
|
+
windowsFallback: ["AppData", "Local"],
|
|
97
|
+
windowsSuffix: ["State"]
|
|
98
|
+
},
|
|
99
|
+
cache: {
|
|
100
|
+
xdg: "XDG_CACHE_HOME",
|
|
101
|
+
unixFallback: [".cache"],
|
|
102
|
+
windowsEnv: "LOCALAPPDATA",
|
|
103
|
+
windowsFallback: ["AppData", "Local"],
|
|
104
|
+
windowsSuffix: ["Cache"]
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
|
|
108
|
+
validatePath(appName, "appName");
|
|
109
|
+
const config = DIRECTORY_CONFIG[kind];
|
|
110
|
+
if (env.platform === "linux" || env.platform === "darwin") {
|
|
111
|
+
const configured = env.env[config.xdg];
|
|
112
|
+
const base = configured?.trim() ? configured : join(env.homedir, ...config.unixFallback);
|
|
113
|
+
return join(base, appName);
|
|
114
|
+
}
|
|
115
|
+
if (env.platform === "win32") {
|
|
116
|
+
const configured = env.env[config.windowsEnv];
|
|
117
|
+
const base = configured?.trim() ? configured : join(env.homedir, ...config.windowsFallback);
|
|
118
|
+
return join(base, appName, ...config.windowsSuffix);
|
|
119
|
+
}
|
|
120
|
+
throw new CrustStoreError("PATH", `Unsupported platform: ${env.platform}`, { path: env.platform });
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolves the platform-standard config directory for the given app.
|
|
124
|
+
*
|
|
125
|
+
* Platform conventions:
|
|
126
|
+
* - **Linux / macOS**: `$XDG_CONFIG_HOME/<appName>` or `~/.config/<appName>`
|
|
127
|
+
* - **Windows**: `%APPDATA%/<appName>` or `~/AppData/Roaming/<appName>`
|
|
128
|
+
*
|
|
129
|
+
* macOS uses XDG conventions for consistency with Linux.
|
|
130
|
+
*
|
|
131
|
+
* @param appName - Application name used as directory name. Must be a non-empty
|
|
132
|
+
* string without path separators.
|
|
133
|
+
* @param env - Optional platform environment override for testing.
|
|
134
|
+
* @returns Absolute path to the app's config directory.
|
|
135
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* import { configDir } from "@crustjs/store";
|
|
140
|
+
*
|
|
141
|
+
* const dir = configDir("my-cli");
|
|
142
|
+
* // → "/home/user/.config/my-cli" (Linux)
|
|
143
|
+
* // → "/Users/user/.config/my-cli" (macOS)
|
|
144
|
+
* // → "C:\\Users\\user\\AppData\\Roaming\\my-cli" (Windows)
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
function configDir(appName, env) {
|
|
148
|
+
return resolvePlatformDir("config", appName, env);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Resolves the platform-standard data directory for the given app.
|
|
152
|
+
*
|
|
153
|
+
* Platform conventions:
|
|
154
|
+
* - **Linux / macOS**: `$XDG_DATA_HOME/<appName>` or `~/.local/share/<appName>`
|
|
155
|
+
* - **Windows**: `%LOCALAPPDATA%/<appName>/Data` or `~/AppData/Local/<appName>/Data`
|
|
156
|
+
*
|
|
157
|
+
* macOS uses XDG conventions for consistency with Linux.
|
|
158
|
+
*
|
|
159
|
+
* @param appName - Application name used as directory name. Must be a non-empty
|
|
160
|
+
* string without path separators.
|
|
161
|
+
* @param env - Optional platform environment override for testing.
|
|
162
|
+
* @returns Absolute path to the app's data directory.
|
|
163
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* import { dataDir } from "@crustjs/store";
|
|
168
|
+
*
|
|
169
|
+
* const dir = dataDir("my-cli");
|
|
170
|
+
* // → "/home/user/.local/share/my-cli" (Linux)
|
|
171
|
+
* // → "/Users/user/.local/share/my-cli" (macOS)
|
|
172
|
+
* // → "C:\\Users\\user\\AppData\\Local\\my-cli\\Data" (Windows)
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
function dataDir(appName, env) {
|
|
176
|
+
return resolvePlatformDir("data", appName, env);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolves the platform-standard state directory for the given app.
|
|
180
|
+
*
|
|
181
|
+
* Platform conventions:
|
|
182
|
+
* - **Linux / macOS**: `$XDG_STATE_HOME/<appName>` or `~/.local/state/<appName>`
|
|
183
|
+
* - **Windows**: `%LOCALAPPDATA%/<appName>/State` or `~/AppData/Local/<appName>/State`
|
|
184
|
+
*
|
|
185
|
+
* macOS uses XDG conventions for consistency with Linux.
|
|
186
|
+
*
|
|
187
|
+
* @param appName - Application name used as directory name. Must be a non-empty
|
|
188
|
+
* string without path separators.
|
|
189
|
+
* @param env - Optional platform environment override for testing.
|
|
190
|
+
* @returns Absolute path to the app's state directory.
|
|
191
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* import { stateDir } from "@crustjs/store";
|
|
196
|
+
*
|
|
197
|
+
* const dir = stateDir("my-cli");
|
|
198
|
+
* // → "/home/user/.local/state/my-cli" (Linux)
|
|
199
|
+
* // → "/Users/user/.local/state/my-cli" (macOS)
|
|
200
|
+
* // → "C:\\Users\\user\\AppData\\Local\\my-cli\\State" (Windows)
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
function stateDir(appName, env) {
|
|
204
|
+
return resolvePlatformDir("state", appName, env);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Resolves the platform-standard cache directory for the given app.
|
|
208
|
+
*
|
|
209
|
+
* Platform conventions:
|
|
210
|
+
* - **Linux / macOS**: `$XDG_CACHE_HOME/<appName>` or `~/.cache/<appName>`
|
|
211
|
+
* - **Windows**: `%LOCALAPPDATA%/<appName>/Cache` or `~/AppData/Local/<appName>/Cache`
|
|
212
|
+
*
|
|
213
|
+
* macOS uses XDG conventions for consistency with Linux.
|
|
214
|
+
*
|
|
215
|
+
* @param appName - Application name used as directory name. Must be a non-empty
|
|
216
|
+
* string without path separators.
|
|
217
|
+
* @param env - Optional platform environment override for testing.
|
|
218
|
+
* @returns Absolute path to the app's cache directory.
|
|
219
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* import { cacheDir } from "@crustjs/store";
|
|
224
|
+
*
|
|
225
|
+
* const dir = cacheDir("my-cli");
|
|
226
|
+
* // → "/home/user/.cache/my-cli" (Linux)
|
|
227
|
+
* // → "/Users/user/.cache/my-cli" (macOS)
|
|
228
|
+
* // → "C:\\Users\\user\\AppData\\Local\\my-cli\\Cache" (Windows)
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
function cacheDir(appName, env) {
|
|
232
|
+
return resolvePlatformDir("cache", appName, env);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Constructs the absolute store file path from a directory and store name.
|
|
236
|
+
*
|
|
237
|
+
* Validates `dirPath` (must be absolute, not end in `.json`) and `name`
|
|
238
|
+
* (no path separators, no `.json` suffix), then joins them as
|
|
239
|
+
* `<dirPath>/<name>.json`.
|
|
240
|
+
*
|
|
241
|
+
* @param dirPath - Absolute directory path.
|
|
242
|
+
* @param name - Store name used as the JSON filename.
|
|
243
|
+
* @returns Absolute path to the store file.
|
|
244
|
+
* @throws {CrustStoreError} `PATH` if `dirPath` or `name` is invalid.
|
|
245
|
+
*/
|
|
246
|
+
function resolveStorePath(dirPath, name) {
|
|
247
|
+
validatePath(dirPath, "dirPath");
|
|
248
|
+
validatePath(name, "name");
|
|
249
|
+
return join(dirPath, `${name}.json`);
|
|
250
|
+
}
|
|
251
|
+
//#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
|
+
//#region src/merge.ts
|
|
350
|
+
/**
|
|
351
|
+
* Applies field defaults to a persisted config object.
|
|
352
|
+
*
|
|
353
|
+
* For each field defined in `fields`:
|
|
354
|
+
* - If the key exists in `persisted`, the persisted value is used.
|
|
355
|
+
* - If the key is missing and the field has a `default`, the default is used.
|
|
356
|
+
* Defaults are deep-copied to prevent shared nested mutation.
|
|
357
|
+
* - If the key is missing and no default exists, the field is omitted
|
|
358
|
+
* (typed as `T | undefined` in the output).
|
|
359
|
+
*
|
|
360
|
+
* When `pruneUnknown` is `true` (the default), keys in `persisted` that are
|
|
361
|
+
* not defined in `fields` are dropped. Set to `false` to preserve them.
|
|
362
|
+
*
|
|
363
|
+
* @param persisted - Parsed JSON from disk, or `undefined` if no file exists.
|
|
364
|
+
* @param fields - Store field definitions.
|
|
365
|
+
* @param pruneUnknown - Whether to drop persisted keys not in `fields`. Defaults to `true`.
|
|
366
|
+
* @returns A new object with field defaults applied.
|
|
367
|
+
*/
|
|
368
|
+
function applyFieldDefaults(persisted, fields, pruneUnknown = true) {
|
|
369
|
+
const result = {};
|
|
370
|
+
for (const [key, def] of Object.entries(fields)) if (persisted && key in persisted) result[key] = persisted[key];
|
|
371
|
+
else if ("default" in def && def.default !== void 0) result[key] = structuredClone(def.default);
|
|
372
|
+
if (!pruneUnknown && persisted !== void 0) {
|
|
373
|
+
for (const [key, value] of Object.entries(persisted)) if (!(key in fields)) result[key] = value;
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
//#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
|
+
//#region src/persistence.ts
|
|
385
|
+
/**
|
|
386
|
+
* Reads and parses a JSON config file from disk.
|
|
387
|
+
*
|
|
388
|
+
* Returns `undefined` when the file does not exist (ENOENT), allowing callers
|
|
389
|
+
* to fall back to defaults. Malformed JSON throws a `CrustStoreError` with `PARSE`
|
|
390
|
+
* code — the store never silently recovers corrupt data.
|
|
391
|
+
*
|
|
392
|
+
* @param filePath - Absolute path to the JSON config file.
|
|
393
|
+
* @returns Parsed JSON value, or `undefined` if the file does not exist.
|
|
394
|
+
* @throws {CrustStoreError} `PARSE` if the file contains malformed JSON.
|
|
395
|
+
* @throws {CrustStoreError} `IO` on filesystem read failures other than ENOENT.
|
|
396
|
+
*/
|
|
397
|
+
async function readJson(filePath) {
|
|
398
|
+
let raw;
|
|
399
|
+
try {
|
|
400
|
+
raw = await readFile(filePath, "utf-8");
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (isEnoent(err)) return;
|
|
403
|
+
throw new CrustStoreError("IO", `Failed to read config file: ${filePath}`, {
|
|
404
|
+
path: filePath,
|
|
405
|
+
operation: "read"
|
|
406
|
+
}, err);
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
return JSON.parse(raw);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
throw new CrustStoreError("PARSE", `Malformed JSON in config file: ${filePath}`, { path: filePath }, err);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Atomically writes a JSON value to disk using a temp-file-and-rename strategy.
|
|
416
|
+
*
|
|
417
|
+
* Steps:
|
|
418
|
+
* 1. Creates the parent directory if it does not exist.
|
|
419
|
+
* 2. Writes serialized JSON to a temporary file in the same directory.
|
|
420
|
+
* 3. Renames the temp file to the target path (atomic on most filesystems).
|
|
421
|
+
*
|
|
422
|
+
* If any step fails, the function cleans up the temp file (best-effort) and
|
|
423
|
+
* throws a `CrustStoreError` with `IO` code.
|
|
424
|
+
*
|
|
425
|
+
* @param filePath - Absolute path to the target JSON config file.
|
|
426
|
+
* @param data - The value to serialize and persist.
|
|
427
|
+
* @param options - Optional file/directory permission bits ({@link WriteJsonOptions}).
|
|
428
|
+
* @throws {CrustStoreError} `IO` on filesystem write failures.
|
|
429
|
+
*/
|
|
430
|
+
async function writeJson(filePath, data, options = {}) {
|
|
431
|
+
const isWindows = process.platform === "win32";
|
|
432
|
+
const fileMode = isWindows ? void 0 : options.fileMode;
|
|
433
|
+
const directoryMode = isWindows ? void 0 : options.directoryMode;
|
|
434
|
+
const dir = dirname(filePath);
|
|
435
|
+
const tempPath = join(dir, `.config-${crypto.randomUUID()}.tmp`);
|
|
436
|
+
let createdDir;
|
|
437
|
+
try {
|
|
438
|
+
createdDir = await mkdir(dir, {
|
|
439
|
+
recursive: true,
|
|
440
|
+
mode: directoryMode
|
|
441
|
+
});
|
|
442
|
+
} catch (err) {
|
|
443
|
+
throw new CrustStoreError("IO", `Failed to create config directory: ${dir}`, {
|
|
444
|
+
path: filePath,
|
|
445
|
+
operation: "write"
|
|
446
|
+
}, err);
|
|
447
|
+
}
|
|
448
|
+
if (directoryMode !== void 0 && createdDir !== void 0) try {
|
|
449
|
+
await chmod(dir, directoryMode);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
throw new CrustStoreError("IO", `Failed to set permissions on config directory: ${dir}`, {
|
|
452
|
+
path: filePath,
|
|
453
|
+
operation: "write"
|
|
454
|
+
}, err);
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const json = JSON.stringify(data, null, " ");
|
|
458
|
+
await writeFile(tempPath, json, {
|
|
459
|
+
encoding: "utf-8",
|
|
460
|
+
mode: fileMode
|
|
461
|
+
});
|
|
462
|
+
if (fileMode !== void 0) await chmod(tempPath, fileMode);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
465
|
+
throw new CrustStoreError("IO", `Failed to write config file: ${filePath}`, {
|
|
466
|
+
path: filePath,
|
|
467
|
+
operation: "write"
|
|
468
|
+
}, err);
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
await rename(tempPath, filePath);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
474
|
+
throw new CrustStoreError("IO", `Failed to finalize config file: ${filePath}`, {
|
|
475
|
+
path: filePath,
|
|
476
|
+
operation: "write"
|
|
477
|
+
}, err);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Deletes a persisted config file from disk.
|
|
482
|
+
*
|
|
483
|
+
* Silently succeeds when the file does not exist (ENOENT) — deleting an
|
|
484
|
+
* already-absent config is not an error.
|
|
485
|
+
*
|
|
486
|
+
* @param filePath - Absolute path to the JSON config file to delete.
|
|
487
|
+
* @throws {CrustStoreError} `IO` on filesystem deletion failures other than ENOENT.
|
|
488
|
+
*/
|
|
489
|
+
async function deleteJson(filePath) {
|
|
490
|
+
try {
|
|
491
|
+
await rm(filePath, { force: true });
|
|
492
|
+
} catch (err) {
|
|
493
|
+
throw new CrustStoreError("IO", `Failed to delete config file: ${filePath}`, {
|
|
494
|
+
path: filePath,
|
|
495
|
+
operation: "delete"
|
|
496
|
+
}, err);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Checks whether a thrown error is a filesystem "file not found" error.
|
|
501
|
+
*/
|
|
502
|
+
function isEnoent(cause) {
|
|
503
|
+
return isErrnoException(cause) && cause.code === "ENOENT";
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/store.ts
|
|
507
|
+
function makeSchemaValidator(schema) {
|
|
508
|
+
return async (value) => {
|
|
509
|
+
const result = await schema["~standard"].validate(value);
|
|
510
|
+
if (result.issues) {
|
|
511
|
+
const messages = normalizeStandardIssues(result.issues).map((issue) => issue.path ? `${issue.path}: ${issue.message}` : issue.message);
|
|
512
|
+
throw new Error(messages.join("; "));
|
|
513
|
+
}
|
|
514
|
+
return { value: result.value };
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** Narrow a field validator return to the exact transform-result shape. */
|
|
518
|
+
function isFieldValueResult(result) {
|
|
519
|
+
return typeof result === "object" && result !== null && Object.hasOwn(result, "value") && Object.keys(result).length === 1;
|
|
520
|
+
}
|
|
521
|
+
function matchesDeclaredType(def, value) {
|
|
522
|
+
const matches = (item) => {
|
|
523
|
+
return typeof item === def.type && (def.type !== "number" || Number.isFinite(item));
|
|
524
|
+
};
|
|
525
|
+
return def.array === true ? Array.isArray(value) && value.every(matches) : matches(value);
|
|
526
|
+
}
|
|
527
|
+
function expectedTypeMessage(def) {
|
|
528
|
+
return `Expected ${def.type}${def.array === true ? "[]" : ""}`;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Creates a typed async config store backed by a local JSON file.
|
|
532
|
+
*
|
|
533
|
+
* The store resolves its file path once at creation time from `dirPath` and
|
|
534
|
+
* `name`. Core field definitions infer from `type` and `default`;
|
|
535
|
+
* schema-backed fields use the Standard Schema's exact output type.
|
|
536
|
+
*
|
|
537
|
+
* @typeParam F - Field definitions record (inferred via `const` generic).
|
|
538
|
+
* @param options - Store configuration options.
|
|
539
|
+
* @returns A {@link Store} instance with `read`, `write`, `update`, `patch`, and `reset` methods.
|
|
540
|
+
* @throws {CrustStoreError} `PATH` if `dirPath` or `name` is invalid.
|
|
541
|
+
* @throws {CrustStoreError} `DEFINITION` if a field mixes `schema` with `default` or `validate`.
|
|
542
|
+
*
|
|
543
|
+
* @example
|
|
544
|
+
* ```ts
|
|
545
|
+
* import { createStore, configDir } from "@crustjs/store";
|
|
546
|
+
*
|
|
547
|
+
* const store = createStore({
|
|
548
|
+
* dirPath: configDir("my-cli"),
|
|
549
|
+
* name: "config",
|
|
550
|
+
* fields: {
|
|
551
|
+
* theme: { type: "string", default: "light" },
|
|
552
|
+
* verbose: { type: "boolean", default: false },
|
|
553
|
+
* token: { type: "string" },
|
|
554
|
+
* },
|
|
555
|
+
* });
|
|
556
|
+
*
|
|
557
|
+
* const config = await store.read();
|
|
558
|
+
* // → { theme: "light", verbose: false, token: undefined }
|
|
559
|
+
*
|
|
560
|
+
* await store.write({ theme: "dark", verbose: true, token: "abc" });
|
|
561
|
+
* await store.update((c) => ({ ...c, theme: "light" }));
|
|
562
|
+
* await store.patch({ theme: "solarized" });
|
|
563
|
+
* await store.reset();
|
|
564
|
+
* ```
|
|
565
|
+
*/
|
|
566
|
+
function createStore(options) {
|
|
567
|
+
const { dirPath, name, fields, pruneUnknown, access } = options;
|
|
568
|
+
const validators = /* @__PURE__ */ new Map();
|
|
569
|
+
for (const [key, def] of Object.entries(fields)) if (def.schema !== void 0) {
|
|
570
|
+
for (const option of ["default", "validate"]) if (def[option] !== void 0) throw new CrustStoreError("DEFINITION", `field "${key}" mixes "schema" with "${option}" — the schema exclusively owns validation, transformation, defaults, and optionality`);
|
|
571
|
+
validators.set(key, makeSchemaValidator(def.schema));
|
|
572
|
+
} else if (def.validate !== void 0) validators.set(key, def.validate);
|
|
573
|
+
const filePath = resolveStorePath(dirPath, name);
|
|
574
|
+
const shouldPrune = pruneUnknown ?? true;
|
|
575
|
+
const writeOptions = access === "private" ? {
|
|
576
|
+
fileMode: 384,
|
|
577
|
+
directoryMode: 448
|
|
578
|
+
} : access === void 0 || access === "default" ? {} : {
|
|
579
|
+
fileMode: access.file,
|
|
580
|
+
directoryMode: access.directory
|
|
581
|
+
};
|
|
582
|
+
function coerceByType(value, type) {
|
|
583
|
+
if (type === "number" && typeof value === "string") return tryCoerceNumber(value) ?? value;
|
|
584
|
+
if (type === "boolean" && typeof value === "string") return coerceBooleanString(value);
|
|
585
|
+
return value;
|
|
586
|
+
}
|
|
587
|
+
function normalizeStateTypes(state) {
|
|
588
|
+
const normalized = { ...state };
|
|
589
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
590
|
+
if (!(key in normalized) || def.schema !== void 0) continue;
|
|
591
|
+
const value = normalized[key];
|
|
592
|
+
if (value === void 0) continue;
|
|
593
|
+
if (def.array === true && Array.isArray(value)) {
|
|
594
|
+
normalized[key] = value.map((item) => coerceByType(item, def.type));
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
normalized[key] = coerceByType(value, def.type);
|
|
598
|
+
}
|
|
599
|
+
return normalized;
|
|
600
|
+
}
|
|
601
|
+
async function runFieldValidators(mutableState, operation) {
|
|
602
|
+
const issues = [];
|
|
603
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
604
|
+
const value = mutableState[key];
|
|
605
|
+
if (value === void 0 && def.schema === void 0) continue;
|
|
606
|
+
if (value !== void 0 && def.schema === void 0 && !matchesDeclaredType(def, value)) {
|
|
607
|
+
issues.push({
|
|
608
|
+
message: expectedTypeMessage(def),
|
|
609
|
+
path: key
|
|
610
|
+
});
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const validator = validators.get(key);
|
|
614
|
+
if (!validator) continue;
|
|
615
|
+
let result;
|
|
616
|
+
try {
|
|
617
|
+
result = await validator(value);
|
|
618
|
+
} catch (cause) {
|
|
619
|
+
const message = cause instanceof Error ? cause.message : "Validation failed";
|
|
620
|
+
issues.push({
|
|
621
|
+
message,
|
|
622
|
+
path: key
|
|
623
|
+
});
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (result === void 0) continue;
|
|
627
|
+
if (isFieldValueResult(result)) {
|
|
628
|
+
const transformed = result.value;
|
|
629
|
+
if (operation === "read") {
|
|
630
|
+
if (value === void 0) mutableState[key] = transformed;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (def.schema === void 0 && !matchesDeclaredType(def, transformed)) {
|
|
634
|
+
issues.push({
|
|
635
|
+
message: expectedTypeMessage(def),
|
|
636
|
+
path: key
|
|
637
|
+
});
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (!isDeepStrictEqual(transformed, value)) {
|
|
641
|
+
let recheck;
|
|
642
|
+
try {
|
|
643
|
+
recheck = await validator(transformed);
|
|
644
|
+
} catch (cause) {
|
|
645
|
+
const message = cause instanceof Error ? cause.message : "re-validation failed";
|
|
646
|
+
issues.push({
|
|
647
|
+
message: `read-unstable transform: ${message}`,
|
|
648
|
+
path: key
|
|
649
|
+
});
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (recheck !== void 0 && (!isFieldValueResult(recheck) || !isDeepStrictEqual(recheck.value, transformed))) {
|
|
653
|
+
issues.push({
|
|
654
|
+
message: `read-unstable transform: output would be transformed again on re-read`,
|
|
655
|
+
path: key
|
|
656
|
+
});
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
mutableState[key] = transformed;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (operation !== "read") for (const [key, value] of Object.entries(mutableState)) {
|
|
664
|
+
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)",
|
|
667
|
+
path: key
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (issues.length > 0) throw new CrustStoreError("VALIDATION", `Store validation failed (${operation})\n${issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")}`, {
|
|
671
|
+
operation,
|
|
672
|
+
issues
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
async function readRaw(materializeSchemaDefaults = false) {
|
|
676
|
+
const persisted = await readJson(filePath);
|
|
677
|
+
const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
|
|
678
|
+
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
|
+
}
|
|
689
|
+
async function read() {
|
|
690
|
+
const document = await readRaw();
|
|
691
|
+
await runFieldValidators(document, "read");
|
|
692
|
+
return document;
|
|
693
|
+
}
|
|
694
|
+
async function write(config) {
|
|
695
|
+
const normalized = normalizeStateTypes({ ...config });
|
|
696
|
+
await runFieldValidators(normalized, "write");
|
|
697
|
+
await writeJson(filePath, normalized, writeOptions);
|
|
698
|
+
return normalized;
|
|
699
|
+
}
|
|
700
|
+
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
|
+
}
|
|
706
|
+
async function patch(partial) {
|
|
707
|
+
const normalized = normalizeStateTypes({
|
|
708
|
+
...await readRaw(true),
|
|
709
|
+
...partial
|
|
710
|
+
});
|
|
711
|
+
await runFieldValidators(normalized, "patch");
|
|
712
|
+
await writeJson(filePath, normalized, writeOptions);
|
|
713
|
+
return normalized;
|
|
714
|
+
}
|
|
715
|
+
async function reset() {
|
|
716
|
+
await deleteJson(filePath);
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
read,
|
|
720
|
+
write,
|
|
721
|
+
update,
|
|
722
|
+
patch,
|
|
723
|
+
reset
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
727
|
+
export { CrustStoreError, cacheDir, configDir, createStore, dataDir, stateDir };
|