@crustjs/store 0.1.1 → 0.3.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/dist/index.js CHANGED
@@ -1,4 +1,727 @@
1
- // @bun
2
- class X extends Error{code;details;cause;constructor(q,H,G){super(H);this.name="CrustStoreError",this.code=q,this.details=G}is(q){return this.code===q}withCause(q){return this.cause=q,this}}import{isStandardSchema as m,normalizeStandardIssues as p}from"@crustjs/utils/schema";function d(q){return async(H)=>{let G=await q["~standard"].validate(H);if(G.issues){let Q=p(G.issues).map((Y)=>Y.path?`${Y.path}: ${Y.message}`:Y.message);throw Error(Q.join("; "))}return{value:G.value}}}function n(q,H){if(!m(q))throw new X("DEFINITION",`field(): argument must be a Standard Schema v1 object (got ${typeof q})`,{});let K={validate:d(q),validateMissing:!0};if(H?.type!==void 0)K.type=H.type;if(H?.array===!0)K.array=!0;if(H?.description!==void 0)K.description=H.description;if(H&&"default"in H)K.default=H.default;return K}import{homedir as o}from"os";import{join as Z}from"path";var l="config";function R(){return{platform:process.platform,env:process.env,homedir:o()}}function U(q){if(!q||q.trim().length===0)throw new X("PATH","appName must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","appName must not contain path separators",{path:q})}function i(q){if(!q||q.trim().length===0)throw new X("PATH","name must be a non-empty string",{path:q??""});if(q.includes("/")||q.includes("\\"))throw new X("PATH","name must not contain path separators",{path:q});if(q.endsWith(".json"))throw new X("PATH","name must not include the .json extension",{path:q})}function s(q){if(!q||q.trim().length===0)throw new X("PATH","dirPath must be a non-empty string",{path:q??""});if(!(q.startsWith("/")||/^[A-Za-z]:[/\\]/.test(q)))throw new X("PATH","dirPath must be an absolute path",{path:q});if(q.endsWith(".json"))throw new X("PATH","dirPath must be a directory path, not a file path (should not end with .json)",{path:q})}function b(q,H,G,K){let Q=q.env[H],Y=Q&&Q.trim().length>0?Q:Z(q.homedir,...G);return Z(Y,K)}function A(q){throw new X("PATH",`Unsupported platform: ${q}`,{path:q})}function t(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_CONFIG_HOME",[".config"],q);case"win32":{let K=G.env.APPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Roaming");return Z(Q,q)}default:A(G.platform)}}function r(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_DATA_HOME",[".local","share"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"Data")}default:A(G.platform)}}function a(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_STATE_HOME",[".local","state"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"State")}default:A(G.platform)}}function e(q,H){U(q);let G=H??R();switch(G.platform){case"linux":case"darwin":return b(G,"XDG_CACHE_HOME",[".cache"],q);case"win32":{let K=G.env.LOCALAPPDATA,Q=K&&K.trim().length>0?K:Z(G.homedir,"AppData","Local");return Z(Q,q,"Cache")}default:A(G.platform)}}function k(q,H){s(q);let G=H??l;if(H!==void 0)i(H);return Z(q,`${G}.json`)}import{coerceBooleanString as Mq,tryCoerceNumber as Wq}from"@crustjs/utils";function w(q,H,G=!0){let K={};for(let[Q,Y]of Object.entries(H))if(q&&Q in q){let x=q[Q];K[Q]=Array.isArray(x)?[...x]:x}else if("default"in Y&&Y.default!==void 0)K[Q]=Array.isArray(Y.default)?[...Y.default]:Y.default;if(!G&&q!==void 0){for(let[Q,Y]of Object.entries(q))if(!(Q in H))K[Q]=Array.isArray(Y)?[...Y]:Y}return K}import{randomUUID as qq}from"crypto";import{mkdir as Gq,readFile as Hq,rename as Kq,rm as Qq,unlink as Xq,writeFile as Yq}from"fs/promises";import{dirname as $q,join as Lq}from"path";async function S(q){let H;try{H=await Hq(q,"utf-8")}catch(G){if(N(G))return;throw new X("IO",`Failed to read config file: ${q}`,{path:q,operation:"read"}).withCause(G)}try{return JSON.parse(H)}catch(G){throw new X("PARSE",`Malformed JSON in config file: ${q}`,{path:q}).withCause(G)}}async function C(q,H){let G=$q(q),K=Lq(G,`.config-${qq()}.tmp`);try{await Gq(G,{recursive:!0})}catch(Q){throw new X("IO",`Failed to create config directory: ${G}`,{path:q,operation:"write"}).withCause(Q)}try{let Q=JSON.stringify(H,null,"\t");await Yq(K,Q,"utf-8")}catch(Q){throw await y(K),new X("IO",`Failed to write config file: ${q}`,{path:q,operation:"write"}).withCause(Q)}try{await Kq(K,q)}catch(Q){throw await y(K),new X("IO",`Failed to finalize config file: ${q}`,{path:q,operation:"write"}).withCause(Q)}}async function g(q){try{await Xq(q)}catch(H){if(N(H))return;throw new X("IO",`Failed to delete config file: ${q}`,{path:q,operation:"delete"}).withCause(H)}}function N(q){return q!==null&&typeof q==="object"&&"code"in q&&q.code==="ENOENT"}async function y(q){try{await Qq(q,{force:!0})}catch{}}function E(q){return typeof q==="object"&&q!==null&&"value"in q&&!("ok"in q)}function Oq(q){let{dirPath:H,name:G,fields:K,pruneUnknown:Q}=q,Y=k(H,G),x=Q??!0;function j($,L){if(L===void 0)return $;if(L==="number"&&typeof $==="string")return Wq($)??$;if(L==="boolean"&&typeof $==="string")return Mq($);return $}function T($){let M={...$};for(let[W,O]of Object.entries(K)){if(!(W in M))continue;let _=M[W];if(O.array===!0&&Array.isArray(_)){M[W]=_.map((B)=>j(B,O.type));continue}M[W]=j(_,O.type)}return M}async function V($,L){let M=[],W=$;for(let[O,_]of Object.entries(K)){if(!_.validate)continue;let B=W[O];if(B===void 0&&_.validateMissing!==!0)continue;let F;try{F=await _.validate(B)}catch(I){let J=I instanceof Error?I.message:"Validation failed";M.push({message:J,path:O});continue}if(F===void 0)continue;if(E(F)){let I=F.value;if(L==="read"){if(B===void 0)W[O]=I;continue}if(!Bun.deepEquals(I,B)){let J;try{J=await _.validate(I)}catch(z){let u=z instanceof Error?z.message:"re-validation failed";M.push({message:`read-unstable transform: ${u}`,path:O});continue}if(J!==void 0&&(!E(J)||!Bun.deepEquals(J.value,I))){M.push({message:"read-unstable transform: output would be transformed again on re-read",path:O});continue}W[O]=I}continue}throw TypeError("FieldDef.validate must return void or { value } (see @crustjs/store validation docs).")}if(M.length>0){let O=M.map((B)=>` - ${B.path}: ${B.message}`),_=`Store validation failed (${L})
3
- ${O.join(`
4
- `)}`;throw new X("VALIDATION",_,{operation:L,issues:M})}}async function D(){let $=await S(Y),L=w($,K,x);return T(L)}async function P(){let $=await D();return await V($,"read"),$}async function c($){let L=T($);await V(L,"write"),await C(Y,L)}async function f($){let L=await D(),M=$(L),W=T(M);await V(W,"update"),await C(Y,W)}async function v($){let M={...await D(),...$},W=T(M);await V(W,"patch"),await C(Y,W)}async function h(){await g(Y)}return{read:P,write:c,update:f,patch:v,reset:h}}export{a as stateDir,n as field,r as dataDir,Oq as createStore,t as configDir,e as cacheDir,X as CrustStoreError};
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 };