@crustjs/store 0.5.0 → 0.5.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/dist/index.d.ts +30 -15
- package/dist/index.js +269 -81
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -110,7 +110,7 @@ type FieldsDef = Record<string, FieldDef>;
|
|
|
110
110
|
type JsonCompatibleFields<F extends FieldsDef> = { [K in keyof F]: F[K] extends {
|
|
111
111
|
schema: infer S extends StandardSchema;
|
|
112
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
|
|
113
|
+
readonly FIX_JSON_OUTPUT: "store schema output must be JSON data: objects, arrays, strings, finite numbers, booleans, or null (not Date, Map, Set, bigint, functions, or undefined outside optional properties)";
|
|
114
114
|
} : F[K]; };
|
|
115
115
|
/**
|
|
116
116
|
* Infer the resolved type for a single field definition.
|
|
@@ -163,7 +163,8 @@ interface StorePermissionBits {
|
|
|
163
163
|
/**
|
|
164
164
|
* Persistence visibility for store writes.
|
|
165
165
|
*
|
|
166
|
-
* - `"default"` or omitted uses platform
|
|
166
|
+
* - `"default"` or omitted uses the platform default for a new file and keeps
|
|
167
|
+
* an existing file's mode.
|
|
167
168
|
* - `"private"` keeps the store owner-only on Unix (`0600` file, `0700` directory).
|
|
168
169
|
* - An object lets advanced callers provide explicit Unix permission bits for
|
|
169
170
|
* group-readable or public non-secret stores without expanding the preset list.
|
|
@@ -211,10 +212,14 @@ interface CreateStoreOptions<F extends FieldsDef> {
|
|
|
211
212
|
*/
|
|
212
213
|
fields: F & JsonCompatibleFields<F>;
|
|
213
214
|
/**
|
|
214
|
-
*
|
|
215
|
-
* are dropped on read. Set to `false` to preserve unknown keys.
|
|
215
|
+
* What mutations do with persisted keys not present in `fields`.
|
|
216
216
|
*
|
|
217
|
-
*
|
|
217
|
+
* `read()` returns only the declared fields either way. When `false` (the
|
|
218
|
+
* default), `write`, `update`, and `patch` keep unknown keys in the file, so
|
|
219
|
+
* an older app version or another tool sharing the file never deletes
|
|
220
|
+
* fields it does not know. Set to `true` to drop them on the next mutation.
|
|
221
|
+
*
|
|
222
|
+
* @default false
|
|
218
223
|
*/
|
|
219
224
|
pruneUnknown?: boolean;
|
|
220
225
|
/**
|
|
@@ -225,6 +230,10 @@ interface CreateStoreOptions<F extends FieldsDef> {
|
|
|
225
230
|
* creates the parent directory as `0700`; on Windows it is not enforced
|
|
226
231
|
* because Windows uses ACLs instead of Unix permission bits.
|
|
227
232
|
*
|
|
233
|
+
* With `"default"`, a new file gets the platform default mode (`0666 & ~umask`
|
|
234
|
+
* on Unix) and a replaced file keeps its current mode, so a user's
|
|
235
|
+
* `chmod 600` survives writes.
|
|
236
|
+
*
|
|
228
237
|
* The only built-in presets are `"default"` and `"private"`. Advanced callers
|
|
229
238
|
* can pass explicit bits with `{ file, directory }` for group-readable or public
|
|
230
239
|
* non-secret stores. The file bits are enforced exactly on every write
|
|
@@ -258,8 +267,9 @@ type StoreUpdater<TConfig> = (current: TConfig) => NoInfer<TConfig>;
|
|
|
258
267
|
*
|
|
259
268
|
* Provides `read`, `write`, `update`, `patch`, and `reset` operations for a
|
|
260
269
|
* single typed config object persisted as JSON on the local filesystem.
|
|
261
|
-
*
|
|
262
|
-
*
|
|
270
|
+
* `write`, `update`, `patch`, and `reset` on the same file run one at a time in
|
|
271
|
+
* call order, across store instances in one process, and hold an advisory lock
|
|
272
|
+
* file (`<name>.json.lock`) across processes.
|
|
263
273
|
*
|
|
264
274
|
* @typeParam TConfig - The inferred config shape from field definitions.
|
|
265
275
|
*
|
|
@@ -315,9 +325,9 @@ interface Store<TConfig> {
|
|
|
315
325
|
* the updater, validates the result, and persists. Invalid current state
|
|
316
326
|
* rejects before the callback runs; use `write` or `patch` to initialize or repair.
|
|
317
327
|
*
|
|
318
|
-
* The
|
|
319
|
-
*
|
|
320
|
-
*
|
|
328
|
+
* The read-modify-write runs after earlier mutations of the same file settle
|
|
329
|
+
* and under its cross-process lock, so concurrent updates keep each other's changes.
|
|
330
|
+
* The final file replacement is atomic.
|
|
321
331
|
*
|
|
322
332
|
* @param updater - Function receiving current config and returning updated config.
|
|
323
333
|
* @returns The persisted config, including schema transformations.
|
|
@@ -334,7 +344,8 @@ interface Store<TConfig> {
|
|
|
334
344
|
* applied but has not undergone full field validation, allowing repairs. The
|
|
335
345
|
* merged result is validated before persistence. Explicit `undefined` is rejected
|
|
336
346
|
* for defaulted core fields; optional core fields and schema-owned optionality
|
|
337
|
-
* remain supported.
|
|
347
|
+
* remain supported. Like {@link Store.update}, the read-modify-write is
|
|
348
|
+
* serialized and locked.
|
|
338
349
|
*
|
|
339
350
|
* @param partial - A partial subset of the config to merge in.
|
|
340
351
|
* @returns The persisted config, including schema transformations.
|
|
@@ -490,7 +501,8 @@ interface PlatformEnv {
|
|
|
490
501
|
* string without path separators.
|
|
491
502
|
* @param env - Optional platform environment override for testing.
|
|
492
503
|
* @returns Absolute path to the app's config directory.
|
|
493
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
504
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
505
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
494
506
|
*
|
|
495
507
|
* @example
|
|
496
508
|
* ```ts
|
|
@@ -516,7 +528,8 @@ export declare function configDir(appName: string, env?: PlatformEnv): string;
|
|
|
516
528
|
* string without path separators.
|
|
517
529
|
* @param env - Optional platform environment override for testing.
|
|
518
530
|
* @returns Absolute path to the app's data directory.
|
|
519
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
531
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
532
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
520
533
|
*
|
|
521
534
|
* @example
|
|
522
535
|
* ```ts
|
|
@@ -542,7 +555,8 @@ export declare function dataDir(appName: string, env?: PlatformEnv): string;
|
|
|
542
555
|
* string without path separators.
|
|
543
556
|
* @param env - Optional platform environment override for testing.
|
|
544
557
|
* @returns Absolute path to the app's state directory.
|
|
545
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
558
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
559
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
546
560
|
*
|
|
547
561
|
* @example
|
|
548
562
|
* ```ts
|
|
@@ -568,7 +582,8 @@ export declare function stateDir(appName: string, env?: PlatformEnv): string;
|
|
|
568
582
|
* string without path separators.
|
|
569
583
|
* @param env - Optional platform environment override for testing.
|
|
570
584
|
* @returns Absolute path to the app's cache directory.
|
|
571
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
585
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
586
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
572
587
|
*
|
|
573
588
|
* @example
|
|
574
589
|
* ```ts
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
import { dirname, join, posix, win32 } from "node:path";
|
|
1
|
+
import { homedir, hostname } from "node:os";
|
|
2
|
+
import { basename, 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
|
-
import { chmod, mkdir, readFile, rename, rm,
|
|
7
|
+
import { chmod, chown, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
8
|
+
import { setTimeout } from "node:timers/promises";
|
|
8
9
|
import { isErrnoException } from "@crustjs/utils/error";
|
|
9
10
|
// src/errors.ts
|
|
10
11
|
/**
|
|
@@ -116,17 +117,16 @@ const DIRECTORY_CONFIG = {
|
|
|
116
117
|
function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
|
|
117
118
|
validateSegment(appName, "appName");
|
|
118
119
|
const config = DIRECTORY_CONFIG[kind];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
throw new CrustStoreError("PATH", `Unsupported platform: ${env.platform}`, { path: env.platform });
|
|
120
|
+
const windows = env.platform === "win32";
|
|
121
|
+
if (!windows && env.platform !== "linux" && env.platform !== "darwin") throw new CrustStoreError("PATH", `Unsupported platform: ${env.platform}`, { path: env.platform });
|
|
122
|
+
const { isAbsolute } = windows ? win32 : posix;
|
|
123
|
+
const variable = windows ? config.windowsEnv : config.xdg;
|
|
124
|
+
const suffix = windows ? config.windowsSuffix : [];
|
|
125
|
+
const configured = env.env[variable];
|
|
126
|
+
if (configured && isAbsolute(configured)) return join(configured, appName, ...suffix);
|
|
127
|
+
if (!isAbsolute(env.homedir)) throw new CrustStoreError("PATH", `Cannot resolve the ${kind} directory: home directory "${env.homedir}" is not absolute; set ${windows ? "USERPROFILE" : "HOME"} or ${variable} to an absolute path`, { path: env.homedir });
|
|
128
|
+
const fallback = windows ? config.windowsFallback : config.unixFallback;
|
|
129
|
+
return join(env.homedir, ...fallback, appName, ...suffix);
|
|
130
130
|
}
|
|
131
131
|
/**
|
|
132
132
|
* Resolves the platform-standard config directory for the given app.
|
|
@@ -141,7 +141,8 @@ function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
|
|
|
141
141
|
* string without path separators.
|
|
142
142
|
* @param env - Optional platform environment override for testing.
|
|
143
143
|
* @returns Absolute path to the app's config directory.
|
|
144
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
144
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
145
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
145
146
|
*
|
|
146
147
|
* @example
|
|
147
148
|
* ```ts
|
|
@@ -169,7 +170,8 @@ function configDir(appName, env) {
|
|
|
169
170
|
* string without path separators.
|
|
170
171
|
* @param env - Optional platform environment override for testing.
|
|
171
172
|
* @returns Absolute path to the app's data directory.
|
|
172
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
173
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
174
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
173
175
|
*
|
|
174
176
|
* @example
|
|
175
177
|
* ```ts
|
|
@@ -197,7 +199,8 @@ function dataDir(appName, env) {
|
|
|
197
199
|
* string without path separators.
|
|
198
200
|
* @param env - Optional platform environment override for testing.
|
|
199
201
|
* @returns Absolute path to the app's state directory.
|
|
200
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
202
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
203
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
201
204
|
*
|
|
202
205
|
* @example
|
|
203
206
|
* ```ts
|
|
@@ -225,7 +228,8 @@ function stateDir(appName, env) {
|
|
|
225
228
|
* string without path separators.
|
|
226
229
|
* @param env - Optional platform environment override for testing.
|
|
227
230
|
* @returns Absolute path to the app's cache directory.
|
|
228
|
-
* @throws {CrustStoreError} `PATH` if `appName` is invalid
|
|
231
|
+
* @throws {CrustStoreError} `PATH` if `appName` is invalid, the platform is unsupported,
|
|
232
|
+
* or the default location needs a home directory that is not an absolute path.
|
|
229
233
|
*
|
|
230
234
|
* @example
|
|
231
235
|
* ```ts
|
|
@@ -271,7 +275,7 @@ function setDocumentValue(document, key, value) {
|
|
|
271
275
|
});
|
|
272
276
|
}
|
|
273
277
|
/**
|
|
274
|
-
* Applies field defaults to a persisted
|
|
278
|
+
* Applies field defaults to a persisted store object.
|
|
275
279
|
*
|
|
276
280
|
* For each field defined in `fields`:
|
|
277
281
|
* - If `persisted` has the key as an own property, the persisted value is used.
|
|
@@ -280,32 +284,39 @@ function setDocumentValue(document, key, value) {
|
|
|
280
284
|
* - If the key is missing and no default exists, the field is omitted
|
|
281
285
|
* (typed as `T | undefined` in the output).
|
|
282
286
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
287
|
+
* Keys in `persisted` that are not defined in `fields` are left out; mutations
|
|
288
|
+
* carry them over with {@link withUnknownKeys}.
|
|
285
289
|
*
|
|
286
|
-
* @param persisted
|
|
287
|
-
* @param fields
|
|
288
|
-
* @param pruneUnknown - Whether to drop persisted keys not in `fields`. Defaults to `true`.
|
|
290
|
+
* @param persisted - Parsed JSON from disk, or `undefined` if no file exists.
|
|
291
|
+
* @param fields - Store field definitions.
|
|
289
292
|
* @returns A new object with field defaults applied.
|
|
290
293
|
*/
|
|
291
|
-
function applyFieldDefaults(persisted, fields
|
|
294
|
+
function applyFieldDefaults(persisted, fields) {
|
|
292
295
|
const result = {};
|
|
293
296
|
for (const [key, def] of Object.entries(fields)) if (persisted && Object.hasOwn(persisted, key)) setDocumentValue(result, key, persisted[key]);
|
|
294
297
|
else if (def.default !== void 0) setDocumentValue(result, key, structuredClone(def.default));
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Returns `document` plus the keys of `persisted` that `fields` does not define,
|
|
302
|
+
* so a store definition that lacks a field (an older app version, another tool)
|
|
303
|
+
* never deletes it. Keys already in `document` win.
|
|
304
|
+
*/
|
|
305
|
+
function withUnknownKeys(document, persisted, fields) {
|
|
306
|
+
const result = { ...document };
|
|
307
|
+
if (persisted === void 0) return result;
|
|
308
|
+
for (const [key, value] of Object.entries(persisted)) if (!Object.hasOwn(fields, key) && !Object.hasOwn(result, key)) setDocumentValue(result, key, value);
|
|
298
309
|
return result;
|
|
299
310
|
}
|
|
300
311
|
// src/persistence.ts
|
|
301
312
|
/**
|
|
302
|
-
* Reads and parses a JSON
|
|
313
|
+
* Reads and parses a JSON store file from disk.
|
|
303
314
|
*
|
|
304
315
|
* Returns `undefined` when the file does not exist (ENOENT), allowing callers
|
|
305
316
|
* to fall back to defaults. Malformed JSON throws a `CrustStoreError` with `PARSE`
|
|
306
317
|
* code — the store never silently recovers corrupt data.
|
|
307
318
|
*
|
|
308
|
-
* @param filePath - Absolute path to the JSON
|
|
319
|
+
* @param filePath - Absolute path to the JSON store file.
|
|
309
320
|
* @returns Parsed JSON value, or `undefined` if the file does not exist.
|
|
310
321
|
* @throws {CrustStoreError} `PARSE` if the file contains malformed JSON.
|
|
311
322
|
* @throws {CrustStoreError} `IO` on filesystem read failures other than ENOENT.
|
|
@@ -316,7 +327,7 @@ async function readJson(filePath) {
|
|
|
316
327
|
raw = await readFile(filePath, "utf-8");
|
|
317
328
|
} catch (err) {
|
|
318
329
|
if (isErrnoException(err) && err.code === "ENOENT") return;
|
|
319
|
-
throw new CrustStoreError("IO", `Failed to read
|
|
330
|
+
throw new CrustStoreError("IO", `Failed to read store file: ${filePath}`, {
|
|
320
331
|
path: filePath,
|
|
321
332
|
operation: "read"
|
|
322
333
|
}, err);
|
|
@@ -324,31 +335,23 @@ async function readJson(filePath) {
|
|
|
324
335
|
try {
|
|
325
336
|
return JSON.parse(raw);
|
|
326
337
|
} catch (err) {
|
|
327
|
-
throw new CrustStoreError("PARSE", `Malformed JSON in
|
|
338
|
+
throw new CrustStoreError("PARSE", `Malformed JSON in store file: ${filePath}`, { path: filePath }, err);
|
|
328
339
|
}
|
|
329
340
|
}
|
|
341
|
+
const isWindows = () => process.platform === "win32";
|
|
342
|
+
/** Whether a failed `chown` only means an unprivileged process cannot give a file away. */
|
|
343
|
+
function isChownErrorOk(err) {
|
|
344
|
+
if (!isErrnoException(err)) return false;
|
|
345
|
+
if (err.code === "ENOSYS") return true;
|
|
346
|
+
return process.getuid?.() !== 0 && (err.code === "EPERM" || err.code === "EINVAL");
|
|
347
|
+
}
|
|
330
348
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* Steps:
|
|
334
|
-
* 1. Creates the parent directory if it does not exist.
|
|
335
|
-
* 2. Writes serialized JSON to a temporary file in the same directory.
|
|
336
|
-
* 3. Renames the temp file to the target path (atomic on most filesystems).
|
|
337
|
-
*
|
|
338
|
-
* If any step fails, the function cleans up the temp file (best-effort) and
|
|
339
|
-
* throws a `CrustStoreError` with `IO` code.
|
|
340
|
-
*
|
|
341
|
-
* @param filePath - Absolute path to the target JSON config file.
|
|
342
|
-
* @param data - The value to serialize and persist.
|
|
343
|
-
* @param options - Optional file/directory permission bits ({@link WriteJsonOptions}).
|
|
344
|
-
* @throws {CrustStoreError} `IO` on filesystem write failures.
|
|
349
|
+
* Creates the store file's parent directory if missing, enforcing `directoryMode`
|
|
350
|
+
* exactly when this call created it.
|
|
345
351
|
*/
|
|
346
|
-
async function
|
|
347
|
-
const
|
|
348
|
-
const fileMode = isWindows ? void 0 : options.fileMode;
|
|
349
|
-
const directoryMode = isWindows ? void 0 : options.directoryMode;
|
|
352
|
+
async function ensureDirectory(filePath, mode) {
|
|
353
|
+
const directoryMode = isWindows() ? void 0 : mode;
|
|
350
354
|
const dir = dirname(filePath);
|
|
351
|
-
const tempPath = join(dir, `.config-${crypto.randomUUID()}.tmp`);
|
|
352
355
|
let createdDir;
|
|
353
356
|
try {
|
|
354
357
|
createdDir = await mkdir(dir, {
|
|
@@ -356,7 +359,7 @@ async function writeJson(filePath, data, options = {}) {
|
|
|
356
359
|
mode: directoryMode
|
|
357
360
|
});
|
|
358
361
|
} catch (err) {
|
|
359
|
-
throw new CrustStoreError("IO", `Failed to create
|
|
362
|
+
throw new CrustStoreError("IO", `Failed to create store directory: ${dir}`, {
|
|
360
363
|
path: filePath,
|
|
361
364
|
operation: "write"
|
|
362
365
|
}, err);
|
|
@@ -364,54 +367,202 @@ async function writeJson(filePath, data, options = {}) {
|
|
|
364
367
|
if (directoryMode !== void 0 && createdDir !== void 0) try {
|
|
365
368
|
await chmod(dir, directoryMode);
|
|
366
369
|
} catch (err) {
|
|
367
|
-
throw new CrustStoreError("IO", `Failed to set permissions on
|
|
370
|
+
throw new CrustStoreError("IO", `Failed to set permissions on store directory: ${dir}`, {
|
|
368
371
|
path: filePath,
|
|
369
372
|
operation: "write"
|
|
370
373
|
}, err);
|
|
371
374
|
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Atomically writes a JSON value to disk using a temp-file-and-rename strategy.
|
|
378
|
+
*
|
|
379
|
+
* Steps:
|
|
380
|
+
* 1. Creates the parent directory if it does not exist.
|
|
381
|
+
* 2. Writes serialized JSON to a temporary file in the same directory and
|
|
382
|
+
* flushes it to disk (`fsync`), so a crash after the rename cannot leave an
|
|
383
|
+
* empty or partial file in place. On POSIX the temp file gets the replaced
|
|
384
|
+
* file's owner and, unless `fileMode` is set, its mode.
|
|
385
|
+
* 3. Renames the temp file to the target path (atomic on local filesystems).
|
|
386
|
+
* On Windows, a rename blocked by another program's open handle (`EPERM`,
|
|
387
|
+
* `EACCES`, `EBUSY`) is retried for up to 2 seconds.
|
|
388
|
+
*
|
|
389
|
+
* If any step fails, the function cleans up the temp file (best-effort) and
|
|
390
|
+
* throws a `CrustStoreError` with `IO` code.
|
|
391
|
+
*
|
|
392
|
+
* @param filePath - Absolute path to the target JSON store file.
|
|
393
|
+
* @param data - The value to serialize and persist.
|
|
394
|
+
* @param options - Optional file/directory permission bits ({@link WriteJsonOptions}).
|
|
395
|
+
* @throws {CrustStoreError} `IO` on filesystem write failures.
|
|
396
|
+
*/
|
|
397
|
+
async function writeJson(filePath, data, options = {}) {
|
|
398
|
+
const tempPath = join(dirname(filePath), `.${basename(filePath, ".json")}-${crypto.randomUUID()}.tmp`);
|
|
399
|
+
await ensureDirectory(filePath, options.directoryMode);
|
|
400
|
+
const existing = isWindows() ? void 0 : await stat(filePath).catch(() => void 0);
|
|
401
|
+
const fileMode = isWindows() ? void 0 : options.fileMode ?? (existing === void 0 ? void 0 : existing.mode & 4095);
|
|
372
402
|
try {
|
|
373
403
|
const json = JSON.stringify(data, null, " ");
|
|
374
|
-
await
|
|
375
|
-
|
|
376
|
-
|
|
404
|
+
const handle = await open(tempPath, "w", fileMode);
|
|
405
|
+
try {
|
|
406
|
+
await handle.writeFile(json, "utf-8");
|
|
407
|
+
await handle.sync();
|
|
408
|
+
} finally {
|
|
409
|
+
await handle.close();
|
|
410
|
+
}
|
|
411
|
+
if (existing !== void 0) await chown(tempPath, existing.uid, existing.gid).catch((err) => {
|
|
412
|
+
if (!isChownErrorOk(err)) throw err;
|
|
377
413
|
});
|
|
378
414
|
if (fileMode !== void 0) await chmod(tempPath, fileMode);
|
|
379
415
|
} catch (err) {
|
|
380
416
|
await rm(tempPath, { force: true }).catch(() => {});
|
|
381
|
-
throw new CrustStoreError("IO", `Failed to write
|
|
417
|
+
throw new CrustStoreError("IO", `Failed to write store file: ${filePath}`, {
|
|
382
418
|
path: filePath,
|
|
383
419
|
operation: "write"
|
|
384
420
|
}, err);
|
|
385
421
|
}
|
|
386
422
|
try {
|
|
387
|
-
await
|
|
423
|
+
await commit(tempPath, filePath);
|
|
388
424
|
} catch (err) {
|
|
389
425
|
await rm(tempPath, { force: true }).catch(() => {});
|
|
390
|
-
throw new CrustStoreError("IO", `Failed to finalize
|
|
426
|
+
throw new CrustStoreError("IO", `Failed to finalize store file: ${filePath}`, {
|
|
391
427
|
path: filePath,
|
|
392
428
|
operation: "write"
|
|
393
429
|
}, err);
|
|
394
430
|
}
|
|
395
431
|
}
|
|
432
|
+
/** How long a rename blocked by another program's open handle is retried on Windows. */
|
|
433
|
+
const RENAME_RETRY_MS = 2e3;
|
|
434
|
+
const MAX_RETRY_DELAY_MS = 100;
|
|
435
|
+
const BUSY_CODES = /* @__PURE__ */ new Set([
|
|
436
|
+
"EPERM",
|
|
437
|
+
"EACCES",
|
|
438
|
+
"EBUSY"
|
|
439
|
+
]);
|
|
396
440
|
/**
|
|
397
|
-
*
|
|
441
|
+
* Renames `tempPath` over `filePath`.
|
|
442
|
+
*
|
|
443
|
+
* Windows refuses to replace a file that any process holds open, including another
|
|
444
|
+
* process reading this store, an antivirus scanner, or an indexer; such handles are
|
|
445
|
+
* usually brief.
|
|
446
|
+
*/
|
|
447
|
+
async function commit(tempPath, filePath) {
|
|
448
|
+
const start = Date.now();
|
|
449
|
+
for (let delay = 1;; delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS)) {
|
|
450
|
+
try {
|
|
451
|
+
return await rename(tempPath, filePath);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
if (!(isWindows() && isErrnoException(err) && BUSY_CODES.has(err.code ?? "")) || Date.now() - start >= RENAME_RETRY_MS) throw err;
|
|
454
|
+
}
|
|
455
|
+
await setTimeout(delay);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Deletes a persisted store file from disk.
|
|
398
460
|
*
|
|
399
461
|
* Silently succeeds when the file does not exist (ENOENT) — deleting an
|
|
400
|
-
* already-absent
|
|
462
|
+
* already-absent file is not an error.
|
|
401
463
|
*
|
|
402
|
-
* @param filePath - Absolute path to the JSON
|
|
464
|
+
* @param filePath - Absolute path to the JSON store file to delete.
|
|
403
465
|
* @throws {CrustStoreError} `IO` on filesystem deletion failures other than ENOENT.
|
|
404
466
|
*/
|
|
405
467
|
async function deleteJson(filePath) {
|
|
406
468
|
try {
|
|
407
469
|
await rm(filePath, { force: true });
|
|
408
470
|
} catch (err) {
|
|
409
|
-
throw new CrustStoreError("IO", `Failed to delete
|
|
471
|
+
throw new CrustStoreError("IO", `Failed to delete store file: ${filePath}`, {
|
|
410
472
|
path: filePath,
|
|
411
473
|
operation: "delete"
|
|
412
474
|
}, err);
|
|
413
475
|
}
|
|
414
476
|
}
|
|
477
|
+
/** A lock older than this was left behind: a mutation takes milliseconds. */
|
|
478
|
+
const STALE_LOCK_MS = 1e4;
|
|
479
|
+
function isProcessAlive(pid) {
|
|
480
|
+
try {
|
|
481
|
+
process.kill(pid, 0);
|
|
482
|
+
return true;
|
|
483
|
+
} catch (err) {
|
|
484
|
+
return isErrnoException(err) && err.code === "EPERM";
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/** The pid a lock file names, or `undefined` when it has none: missing, not yet written, or another host's. */
|
|
488
|
+
async function readLockPid(lockPath) {
|
|
489
|
+
try {
|
|
490
|
+
const owner = JSON.parse(await readFile(lockPath, "utf-8"));
|
|
491
|
+
return owner?.host === hostname() && typeof owner.pid === "number" ? owner.pid : void 0;
|
|
492
|
+
} catch {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** Whether the lock was left behind: its process is gone, or it is older than {@link STALE_LOCK_MS}. */
|
|
497
|
+
async function isStaleLock(lockPath) {
|
|
498
|
+
const pid = await readLockPid(lockPath);
|
|
499
|
+
if (pid !== void 0 && !isProcessAlive(pid) && await readLockPid(lockPath) === pid) return true;
|
|
500
|
+
return stat(lockPath).then((lock) => Date.now() - lock.mtimeMs > STALE_LOCK_MS, () => false);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Runs `fn` while holding `<filePath>.lock`, an advisory lock shared by every
|
|
504
|
+
* process that uses this function on the same file.
|
|
505
|
+
*
|
|
506
|
+
* The lock file is created exclusively (`wx`) and holds the owner's pid and host.
|
|
507
|
+
* Waiters retry with a growing, jittered delay and remove a lock whose process
|
|
508
|
+
* is gone or that is older than 10 seconds. After `fn` settles the lock is removed,
|
|
509
|
+
* unless a takeover has put another process's lock at the path.
|
|
510
|
+
*
|
|
511
|
+
* shortcut: checking a lock and removing it are two steps, so two waiters that find the
|
|
512
|
+
* same abandoned lock at the same instant can both take it, and a mutation running past
|
|
513
|
+
* 10 seconds loses its lock; either costs lost updates, never a corrupt file. Use an
|
|
514
|
+
* OS file lock or a heartbeat if stores ever need exclusion to survive a crashed writer.
|
|
515
|
+
*
|
|
516
|
+
* @throws {CrustStoreError} `IO` when the lock file cannot be created or an abandoned one removed.
|
|
517
|
+
*/
|
|
518
|
+
async function withFileLock(filePath, fn, options) {
|
|
519
|
+
const lockPath = `${filePath}.lock`;
|
|
520
|
+
const lockError = (err) => new CrustStoreError("IO", `Failed to lock store file: ${filePath}`, {
|
|
521
|
+
path: filePath,
|
|
522
|
+
operation: options.operation
|
|
523
|
+
}, err);
|
|
524
|
+
let refusedSince;
|
|
525
|
+
const isContention = (err) => {
|
|
526
|
+
const code = isWindows() && isErrnoException(err) ? err.code ?? "" : "";
|
|
527
|
+
if (code !== "EFAULT" && !BUSY_CODES.has(code)) return false;
|
|
528
|
+
refusedSince ??= Date.now();
|
|
529
|
+
return Date.now() - refusedSince < STALE_LOCK_MS;
|
|
530
|
+
};
|
|
531
|
+
let named = true;
|
|
532
|
+
for (let delay = 1;; delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS)) {
|
|
533
|
+
try {
|
|
534
|
+
const handle = await open(lockPath, "wx");
|
|
535
|
+
await handle.writeFile(JSON.stringify({
|
|
536
|
+
pid: process.pid,
|
|
537
|
+
host: hostname()
|
|
538
|
+
})).catch(() => {
|
|
539
|
+
named = false;
|
|
540
|
+
});
|
|
541
|
+
await handle.close();
|
|
542
|
+
break;
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
545
|
+
if (options.operation === "delete") return await fn();
|
|
546
|
+
await ensureDirectory(filePath, options.directoryMode);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (!(isErrnoException(err) && err.code === "EEXIST") && !isContention(err)) throw lockError(err);
|
|
550
|
+
}
|
|
551
|
+
if (!(await isStaleLock(lockPath) && await rm(lockPath, { force: true }).then(() => true, (err) => {
|
|
552
|
+
if (!isContention(err)) throw lockError(err);
|
|
553
|
+
return false;
|
|
554
|
+
}))) await setTimeout(delay * (1 + Math.random()));
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
return await fn();
|
|
558
|
+
} finally {
|
|
559
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
560
|
+
if (named && await readLockPid(lockPath) !== process.pid) break;
|
|
561
|
+
if (await rm(lockPath, { force: true }).then(() => true, () => !named)) break;
|
|
562
|
+
await setTimeout(10);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
415
566
|
// src/store.ts
|
|
416
567
|
function makeSchemaValidator(schema) {
|
|
417
568
|
return async (value) => {
|
|
@@ -476,6 +627,18 @@ function jsonInstabilityIssues(document) {
|
|
|
476
627
|
}
|
|
477
628
|
return issues;
|
|
478
629
|
}
|
|
630
|
+
/** Tail of each store file's mutation queue, shared by every store instance in the process. */
|
|
631
|
+
const mutationQueues = /* @__PURE__ */ new Map();
|
|
632
|
+
/** Runs `task` after every mutation already queued for `filePath` settles, in call order. */
|
|
633
|
+
function enqueue(filePath, task) {
|
|
634
|
+
const run = (mutationQueues.get(filePath) ?? Promise.resolve()).then(task);
|
|
635
|
+
const tail = run.catch(() => {});
|
|
636
|
+
mutationQueues.set(filePath, tail);
|
|
637
|
+
tail.then(() => {
|
|
638
|
+
if (mutationQueues.get(filePath) === tail) mutationQueues.delete(filePath);
|
|
639
|
+
});
|
|
640
|
+
return run;
|
|
641
|
+
}
|
|
479
642
|
/**
|
|
480
643
|
* Creates a typed async config store backed by a local JSON file.
|
|
481
644
|
*
|
|
@@ -609,37 +772,62 @@ function createStore(options) {
|
|
|
609
772
|
issues
|
|
610
773
|
});
|
|
611
774
|
}
|
|
612
|
-
async function
|
|
775
|
+
async function readPersisted() {
|
|
613
776
|
const persisted = await readJson(filePath);
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
777
|
+
if (persisted === void 0 || isJsonObject(persisted)) return persisted;
|
|
778
|
+
throw new CrustStoreError("PARSE", `Expected a JSON object in store file: ${filePath}`, { path: filePath });
|
|
779
|
+
}
|
|
780
|
+
/** The declared fields of a persisted object, with defaults and core coercion applied. */
|
|
781
|
+
function toDocument(persisted) {
|
|
782
|
+
return normalizeStateTypes(applyFieldDefaults(persisted, fields));
|
|
783
|
+
}
|
|
784
|
+
/** Serialized within the process, then locked across processes. */
|
|
785
|
+
function mutate(operation, task) {
|
|
786
|
+
return enqueue(filePath, () => withFileLock(filePath, task, {
|
|
787
|
+
operation,
|
|
788
|
+
directoryMode: writeOptions.directoryMode
|
|
789
|
+
}));
|
|
617
790
|
}
|
|
618
791
|
async function read() {
|
|
619
|
-
const document = await
|
|
792
|
+
const document = toDocument(await readPersisted());
|
|
620
793
|
await runFieldValidators(document, "read");
|
|
621
794
|
return document;
|
|
622
795
|
}
|
|
623
|
-
async function persist(document, operation) {
|
|
796
|
+
async function persist(document, operation, persisted) {
|
|
624
797
|
const normalized = normalizeStateTypes(document);
|
|
625
798
|
await runFieldValidators(normalized, operation);
|
|
626
|
-
|
|
799
|
+
const output = pruneUnknown === true ? normalized : withUnknownKeys(normalized, persisted, fields);
|
|
800
|
+
await writeJson(filePath, output, writeOptions);
|
|
627
801
|
return normalized;
|
|
628
802
|
}
|
|
629
|
-
|
|
630
|
-
return
|
|
803
|
+
function write(config) {
|
|
804
|
+
return mutate("write", async () => {
|
|
805
|
+
const persisted = pruneUnknown === true ? void 0 : await readPersisted().catch((err) => {
|
|
806
|
+
if (err instanceof CrustStoreError && err.is("PARSE")) return void 0;
|
|
807
|
+
throw err;
|
|
808
|
+
});
|
|
809
|
+
return persist({ ...config }, "write", persisted);
|
|
810
|
+
});
|
|
631
811
|
}
|
|
632
|
-
|
|
633
|
-
return
|
|
812
|
+
function update(updater) {
|
|
813
|
+
return mutate("write", async () => {
|
|
814
|
+
const persisted = await readPersisted();
|
|
815
|
+
const current = toDocument(persisted);
|
|
816
|
+
await runFieldValidators(current, "read");
|
|
817
|
+
return persist({ ...updater(current) }, "update", persisted);
|
|
818
|
+
});
|
|
634
819
|
}
|
|
635
|
-
|
|
636
|
-
return
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
820
|
+
function patch(partial) {
|
|
821
|
+
return mutate("write", async () => {
|
|
822
|
+
const persisted = await readPersisted();
|
|
823
|
+
return persist({
|
|
824
|
+
...toDocument(persisted),
|
|
825
|
+
...partial
|
|
826
|
+
}, "patch", persisted);
|
|
827
|
+
});
|
|
640
828
|
}
|
|
641
|
-
|
|
642
|
-
|
|
829
|
+
function reset() {
|
|
830
|
+
return mutate("delete", () => deleteJson(filePath));
|
|
643
831
|
}
|
|
644
832
|
return {
|
|
645
833
|
read,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crustjs/store",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Typed JSON persistence for CLI config, data, state, and cache files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@crustjs/utils": "^0.3.
|
|
43
|
+
"@crustjs/utils": "^0.3.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@crustjs/config": "0.0.0",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"vite-plus": "1.4.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"typescript": "
|
|
51
|
+
"typescript": ">=5.9.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"typescript": {
|