@crustjs/store 0.4.4 → 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 CHANGED
@@ -1,7 +1,6 @@
1
1
  import { JsonCompatible } from "@crustjs/utils/json";
2
2
  import { BaseValueType, ResolvePrimitive } from "@crustjs/utils/primitive";
3
3
  import { InferOutput, StandardSchema } from "@crustjs/utils/schema";
4
- //#region src/types.d.ts
5
4
  /** Supported type literals for store fields. */
6
5
  type ValueType = BaseValueType;
7
6
  /** Shared fields present on every store field definition. */
@@ -109,8 +108,10 @@ type FieldDef = StringFieldDef | NumberFieldDef | BooleanFieldDef | StringArrayF
109
108
  /** Record mapping field names to their definitions. */
110
109
  type FieldsDef = Record<string, FieldDef>;
111
110
  type JsonCompatibleFields<F extends FieldsDef> = { [K in keyof F]: F[K] extends {
112
- schema: StandardSchema<unknown, infer Output>;
113
- } ? Exclude<Output, undefined> extends JsonCompatible<Exclude<Output, undefined>> ? F[K] : never : F[K]; };
111
+ schema: infer S extends StandardSchema;
112
+ } ? Exclude<InferOutput<S>, undefined> extends JsonCompatible<Exclude<InferOutput<S>, undefined>> ? F[K] : F[K] & {
113
+ readonly FIX_JSON_OUTPUT: "store schema output must be JSON data: objects, arrays, strings, finite numbers, booleans, or null (not Date, Map, Set, bigint, functions, or undefined outside optional properties)";
114
+ } : F[K]; };
114
115
  /**
115
116
  * Infer the resolved type for a single field definition.
116
117
  *
@@ -162,7 +163,8 @@ interface StorePermissionBits {
162
163
  /**
163
164
  * Persistence visibility for store writes.
164
165
  *
165
- * - `"default"` or omitted uses platform defaults.
166
+ * - `"default"` or omitted uses the platform default for a new file and keeps
167
+ * an existing file's mode.
166
168
  * - `"private"` keeps the store owner-only on Unix (`0600` file, `0700` directory).
167
169
  * - An object lets advanced callers provide explicit Unix permission bits for
168
170
  * group-readable or public non-secret stores without expanding the preset list.
@@ -210,10 +212,14 @@ interface CreateStoreOptions<F extends FieldsDef> {
210
212
  */
211
213
  fields: F & JsonCompatibleFields<F>;
212
214
  /**
213
- * When `true` (the default), persisted keys not present in `fields`
214
- * are dropped on read. Set to `false` to preserve unknown keys.
215
+ * What mutations do with persisted keys not present in `fields`.
216
+ *
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.
215
221
  *
216
- * @default true
222
+ * @default false
217
223
  */
218
224
  pruneUnknown?: boolean;
219
225
  /**
@@ -224,6 +230,10 @@ interface CreateStoreOptions<F extends FieldsDef> {
224
230
  * creates the parent directory as `0700`; on Windows it is not enforced
225
231
  * because Windows uses ACLs instead of Unix permission bits.
226
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
+ *
227
237
  * The only built-in presets are `"default"` and `"private"`. Advanced callers
228
238
  * can pass explicit bits with `{ file, directory }` for group-readable or public
229
239
  * non-secret stores. The file bits are enforced exactly on every write
@@ -257,8 +267,9 @@ type StoreUpdater<TConfig> = (current: TConfig) => NoInfer<TConfig>;
257
267
  *
258
268
  * Provides `read`, `write`, `update`, `patch`, and `reset` operations for a
259
269
  * single typed config object persisted as JSON on the local filesystem.
260
- * Mutations are not serialized, including overlapping calls in one process.
261
- * Await mutations sequentially for each file; coordinate multiple processes externally.
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.
262
273
  *
263
274
  * @typeParam TConfig - The inferred config shape from field definitions.
264
275
  *
@@ -314,9 +325,9 @@ interface Store<TConfig> {
314
325
  * the updater, validates the result, and persists. Invalid current state
315
326
  * rejects before the callback runs; use `write` or `patch` to initialize or repair.
316
327
  *
317
- * The final file replacement is atomic, but the read-modify-write sequence
318
- * is not serialized, even within one process. Await mutations sequentially
319
- * to avoid lost updates; coordinate multiple processes externally.
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.
320
331
  *
321
332
  * @param updater - Function receiving current config and returning updated config.
322
333
  * @returns The persisted config, including schema transformations.
@@ -333,7 +344,8 @@ interface Store<TConfig> {
333
344
  * applied but has not undergone full field validation, allowing repairs. The
334
345
  * merged result is validated before persistence. Explicit `undefined` is rejected
335
346
  * for defaulted core fields; optional core fields and schema-owned optionality
336
- * remain supported. Await mutations sequentially to avoid lost updates.
347
+ * remain supported. Like {@link Store.update}, the read-modify-write is
348
+ * serialized and locked.
337
349
  *
338
350
  * @param partial - A partial subset of the config to merge in.
339
351
  * @returns The persisted config, including schema transformations.
@@ -350,8 +362,6 @@ interface Store<TConfig> {
350
362
  */
351
363
  reset(): Promise<void>;
352
364
  }
353
- //#endregion
354
- //#region src/errors.d.ts
355
365
  /**
356
366
  * Contextual details attached to an `IO` error.
357
367
  *
@@ -464,8 +474,6 @@ export declare class CrustStoreError<C extends StoreErrorCode = StoreErrorCode>
464
474
  */
465
475
  is<T extends StoreErrorCode>(code: T): this is CrustStoreError<T>;
466
476
  }
467
- //#endregion
468
- //#region src/path.d.ts
469
477
  /**
470
478
  * Encapsulates runtime environment values needed for path resolution.
471
479
  *
@@ -493,7 +501,8 @@ interface PlatformEnv {
493
501
  * string without path separators.
494
502
  * @param env - Optional platform environment override for testing.
495
503
  * @returns Absolute path to the app's config directory.
496
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
497
506
  *
498
507
  * @example
499
508
  * ```ts
@@ -519,7 +528,8 @@ export declare function configDir(appName: string, env?: PlatformEnv): string;
519
528
  * string without path separators.
520
529
  * @param env - Optional platform environment override for testing.
521
530
  * @returns Absolute path to the app's data directory.
522
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
523
533
  *
524
534
  * @example
525
535
  * ```ts
@@ -545,7 +555,8 @@ export declare function dataDir(appName: string, env?: PlatformEnv): string;
545
555
  * string without path separators.
546
556
  * @param env - Optional platform environment override for testing.
547
557
  * @returns Absolute path to the app's state directory.
548
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
549
560
  *
550
561
  * @example
551
562
  * ```ts
@@ -571,7 +582,8 @@ export declare function stateDir(appName: string, env?: PlatformEnv): string;
571
582
  * string without path separators.
572
583
  * @param env - Optional platform environment override for testing.
573
584
  * @returns Absolute path to the app's cache directory.
574
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
575
587
  *
576
588
  * @example
577
589
  * ```ts
@@ -584,8 +596,6 @@ export declare function stateDir(appName: string, env?: PlatformEnv): string;
584
596
  * ```
585
597
  */
586
598
  export declare function cacheDir(appName: string, env?: PlatformEnv): string;
587
- //#endregion
588
- //#region src/store.d.ts
589
599
  /**
590
600
  * Creates a typed async config store backed by a local JSON file.
591
601
  *
@@ -623,5 +633,4 @@ export declare function cacheDir(appName: string, env?: PlatformEnv): string;
623
633
  * ```
624
634
  */
625
635
  export declare function createStore<const F extends FieldsDef>(options: CreateStoreOptions<F>): Store<InferStoreConfig<F>>;
626
- //#endregion
627
636
  export type { CreateStoreOptions, FieldDef, FieldsDef, InferStoreConfig, PlatformEnv, Store, StoreAccess, StoreErrorCode, StorePermissionBits, StoreUpdater, StoreValidatorIssue, ValidationErrorDetails, ValueType };
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
- import { homedir } from "node:os";
2
- import { dirname, join, 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, writeFile } from "node:fs/promises";
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
- //#region src/errors.ts
10
+ // src/errors.ts
10
11
  /**
11
12
  * A typed error thrown by `@crustjs/store` for path, parse, IO, and validation failures.
12
13
  *
@@ -56,8 +57,7 @@ var CrustStoreError = class extends Error {
56
57
  return Object.is(this.code, code);
57
58
  }
58
59
  };
59
- //#endregion
60
- //#region src/path.ts
60
+ // src/path.ts
61
61
  /**
62
62
  * Returns the current runtime environment for path resolution.
63
63
  *
@@ -117,17 +117,16 @@ const DIRECTORY_CONFIG = {
117
117
  function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
118
118
  validateSegment(appName, "appName");
119
119
  const config = DIRECTORY_CONFIG[kind];
120
- if (env.platform === "linux" || env.platform === "darwin") {
121
- const configured = env.env[config.xdg];
122
- const base = configured?.trim() ? configured : join(env.homedir, ...config.unixFallback);
123
- return join(base, appName);
124
- }
125
- if (env.platform === "win32") {
126
- const configured = env.env[config.windowsEnv];
127
- const base = configured?.trim() ? configured : join(env.homedir, ...config.windowsFallback);
128
- return join(base, appName, ...config.windowsSuffix);
129
- }
130
- 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);
131
130
  }
132
131
  /**
133
132
  * Resolves the platform-standard config directory for the given app.
@@ -142,7 +141,8 @@ function resolvePlatformDir(kind, appName, env = getRuntimeEnv()) {
142
141
  * string without path separators.
143
142
  * @param env - Optional platform environment override for testing.
144
143
  * @returns Absolute path to the app's config directory.
145
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
146
146
  *
147
147
  * @example
148
148
  * ```ts
@@ -170,7 +170,8 @@ function configDir(appName, env) {
170
170
  * string without path separators.
171
171
  * @param env - Optional platform environment override for testing.
172
172
  * @returns Absolute path to the app's data directory.
173
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
174
175
  *
175
176
  * @example
176
177
  * ```ts
@@ -198,7 +199,8 @@ function dataDir(appName, env) {
198
199
  * string without path separators.
199
200
  * @param env - Optional platform environment override for testing.
200
201
  * @returns Absolute path to the app's state directory.
201
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
202
204
  *
203
205
  * @example
204
206
  * ```ts
@@ -226,7 +228,8 @@ function stateDir(appName, env) {
226
228
  * string without path separators.
227
229
  * @param env - Optional platform environment override for testing.
228
230
  * @returns Absolute path to the app's cache directory.
229
- * @throws {CrustStoreError} `PATH` if `appName` is invalid or platform is unsupported.
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.
230
233
  *
231
234
  * @example
232
235
  * ```ts
@@ -258,8 +261,7 @@ function resolveStorePath(dirPath, name) {
258
261
  validateSegment(name, "name");
259
262
  return join(dirPath, `${name}.json`);
260
263
  }
261
- //#endregion
262
- //#region src/merge.ts
264
+ // src/merge.ts
263
265
  /**
264
266
  * Sets an own enumerable data property. Plain assignment would invoke the
265
267
  * inherited `__proto__` setter instead of storing a `__proto__` field.
@@ -273,7 +275,7 @@ function setDocumentValue(document, key, value) {
273
275
  });
274
276
  }
275
277
  /**
276
- * Applies field defaults to a persisted config object.
278
+ * Applies field defaults to a persisted store object.
277
279
  *
278
280
  * For each field defined in `fields`:
279
281
  * - If `persisted` has the key as an own property, the persisted value is used.
@@ -282,33 +284,39 @@ function setDocumentValue(document, key, value) {
282
284
  * - If the key is missing and no default exists, the field is omitted
283
285
  * (typed as `T | undefined` in the output).
284
286
  *
285
- * When `pruneUnknown` is `true` (the default), keys in `persisted` that are
286
- * not defined in `fields` are dropped. Set to `false` to preserve them.
287
+ * Keys in `persisted` that are not defined in `fields` are left out; mutations
288
+ * carry them over with {@link withUnknownKeys}.
287
289
  *
288
- * @param persisted - Parsed JSON from disk, or `undefined` if no file exists.
289
- * @param fields - Store field definitions.
290
- * @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.
291
292
  * @returns A new object with field defaults applied.
292
293
  */
293
- function applyFieldDefaults(persisted, fields, pruneUnknown = true) {
294
+ function applyFieldDefaults(persisted, fields) {
294
295
  const result = {};
295
296
  for (const [key, def] of Object.entries(fields)) if (persisted && Object.hasOwn(persisted, key)) setDocumentValue(result, key, persisted[key]);
296
297
  else if (def.default !== void 0) setDocumentValue(result, key, structuredClone(def.default));
297
- if (!pruneUnknown && persisted !== void 0) {
298
- for (const [key, value] of Object.entries(persisted)) if (!Object.hasOwn(fields, key)) setDocumentValue(result, key, value);
299
- }
300
298
  return result;
301
299
  }
302
- //#endregion
303
- //#region src/persistence.ts
304
300
  /**
305
- * Reads and parses a JSON config file from disk.
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);
309
+ return result;
310
+ }
311
+ // src/persistence.ts
312
+ /**
313
+ * Reads and parses a JSON store file from disk.
306
314
  *
307
315
  * Returns `undefined` when the file does not exist (ENOENT), allowing callers
308
316
  * to fall back to defaults. Malformed JSON throws a `CrustStoreError` with `PARSE`
309
317
  * code — the store never silently recovers corrupt data.
310
318
  *
311
- * @param filePath - Absolute path to the JSON config file.
319
+ * @param filePath - Absolute path to the JSON store file.
312
320
  * @returns Parsed JSON value, or `undefined` if the file does not exist.
313
321
  * @throws {CrustStoreError} `PARSE` if the file contains malformed JSON.
314
322
  * @throws {CrustStoreError} `IO` on filesystem read failures other than ENOENT.
@@ -319,7 +327,7 @@ async function readJson(filePath) {
319
327
  raw = await readFile(filePath, "utf-8");
320
328
  } catch (err) {
321
329
  if (isErrnoException(err) && err.code === "ENOENT") return;
322
- throw new CrustStoreError("IO", `Failed to read config file: ${filePath}`, {
330
+ throw new CrustStoreError("IO", `Failed to read store file: ${filePath}`, {
323
331
  path: filePath,
324
332
  operation: "read"
325
333
  }, err);
@@ -327,31 +335,23 @@ async function readJson(filePath) {
327
335
  try {
328
336
  return JSON.parse(raw);
329
337
  } catch (err) {
330
- throw new CrustStoreError("PARSE", `Malformed JSON in config file: ${filePath}`, { path: filePath }, err);
338
+ throw new CrustStoreError("PARSE", `Malformed JSON in store file: ${filePath}`, { path: filePath }, err);
331
339
  }
332
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
+ }
333
348
  /**
334
- * Atomically writes a JSON value to disk using a temp-file-and-rename strategy.
335
- *
336
- * Steps:
337
- * 1. Creates the parent directory if it does not exist.
338
- * 2. Writes serialized JSON to a temporary file in the same directory.
339
- * 3. Renames the temp file to the target path (atomic on most filesystems).
340
- *
341
- * If any step fails, the function cleans up the temp file (best-effort) and
342
- * throws a `CrustStoreError` with `IO` code.
343
- *
344
- * @param filePath - Absolute path to the target JSON config file.
345
- * @param data - The value to serialize and persist.
346
- * @param options - Optional file/directory permission bits ({@link WriteJsonOptions}).
347
- * @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.
348
351
  */
349
- async function writeJson(filePath, data, options = {}) {
350
- const isWindows = process.platform === "win32";
351
- const fileMode = isWindows ? void 0 : options.fileMode;
352
- const directoryMode = isWindows ? void 0 : options.directoryMode;
352
+ async function ensureDirectory(filePath, mode) {
353
+ const directoryMode = isWindows() ? void 0 : mode;
353
354
  const dir = dirname(filePath);
354
- const tempPath = join(dir, `.config-${crypto.randomUUID()}.tmp`);
355
355
  let createdDir;
356
356
  try {
357
357
  createdDir = await mkdir(dir, {
@@ -359,7 +359,7 @@ async function writeJson(filePath, data, options = {}) {
359
359
  mode: directoryMode
360
360
  });
361
361
  } catch (err) {
362
- throw new CrustStoreError("IO", `Failed to create config directory: ${dir}`, {
362
+ throw new CrustStoreError("IO", `Failed to create store directory: ${dir}`, {
363
363
  path: filePath,
364
364
  operation: "write"
365
365
  }, err);
@@ -367,56 +367,203 @@ async function writeJson(filePath, data, options = {}) {
367
367
  if (directoryMode !== void 0 && createdDir !== void 0) try {
368
368
  await chmod(dir, directoryMode);
369
369
  } catch (err) {
370
- throw new CrustStoreError("IO", `Failed to set permissions on config directory: ${dir}`, {
370
+ throw new CrustStoreError("IO", `Failed to set permissions on store directory: ${dir}`, {
371
371
  path: filePath,
372
372
  operation: "write"
373
373
  }, err);
374
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);
375
402
  try {
376
403
  const json = JSON.stringify(data, null, " ");
377
- await writeFile(tempPath, json, {
378
- encoding: "utf-8",
379
- mode: fileMode
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;
380
413
  });
381
414
  if (fileMode !== void 0) await chmod(tempPath, fileMode);
382
415
  } catch (err) {
383
416
  await rm(tempPath, { force: true }).catch(() => {});
384
- throw new CrustStoreError("IO", `Failed to write config file: ${filePath}`, {
417
+ throw new CrustStoreError("IO", `Failed to write store file: ${filePath}`, {
385
418
  path: filePath,
386
419
  operation: "write"
387
420
  }, err);
388
421
  }
389
422
  try {
390
- await rename(tempPath, filePath);
423
+ await commit(tempPath, filePath);
391
424
  } catch (err) {
392
425
  await rm(tempPath, { force: true }).catch(() => {});
393
- throw new CrustStoreError("IO", `Failed to finalize config file: ${filePath}`, {
426
+ throw new CrustStoreError("IO", `Failed to finalize store file: ${filePath}`, {
394
427
  path: filePath,
395
428
  operation: "write"
396
429
  }, err);
397
430
  }
398
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
+ ]);
399
440
  /**
400
- * Deletes a persisted config file from disk.
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.
401
460
  *
402
461
  * Silently succeeds when the file does not exist (ENOENT) — deleting an
403
- * already-absent config is not an error.
462
+ * already-absent file is not an error.
404
463
  *
405
- * @param filePath - Absolute path to the JSON config file to delete.
464
+ * @param filePath - Absolute path to the JSON store file to delete.
406
465
  * @throws {CrustStoreError} `IO` on filesystem deletion failures other than ENOENT.
407
466
  */
408
467
  async function deleteJson(filePath) {
409
468
  try {
410
469
  await rm(filePath, { force: true });
411
470
  } catch (err) {
412
- throw new CrustStoreError("IO", `Failed to delete config file: ${filePath}`, {
471
+ throw new CrustStoreError("IO", `Failed to delete store file: ${filePath}`, {
413
472
  path: filePath,
414
473
  operation: "delete"
415
474
  }, err);
416
475
  }
417
476
  }
418
- //#endregion
419
- //#region src/store.ts
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
+ }
566
+ // src/store.ts
420
567
  function makeSchemaValidator(schema) {
421
568
  return async (value) => {
422
569
  const result = await schema["~standard"].validate(value);
@@ -480,6 +627,18 @@ function jsonInstabilityIssues(document) {
480
627
  }
481
628
  return issues;
482
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
+ }
483
642
  /**
484
643
  * Creates a typed async config store backed by a local JSON file.
485
644
  *
@@ -613,37 +772,62 @@ function createStore(options) {
613
772
  issues
614
773
  });
615
774
  }
616
- async function readRaw() {
775
+ async function readPersisted() {
617
776
  const persisted = await readJson(filePath);
618
- const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
619
- if (persisted !== void 0 && persistedObject === void 0) throw new CrustStoreError("PARSE", `Expected a JSON object in config file: ${filePath}`, { path: filePath });
620
- return normalizeStateTypes(applyFieldDefaults(persistedObject, fields, pruneUnknown));
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
+ }));
621
790
  }
622
791
  async function read() {
623
- const document = await readRaw();
792
+ const document = toDocument(await readPersisted());
624
793
  await runFieldValidators(document, "read");
625
794
  return document;
626
795
  }
627
- async function persist(document, operation) {
796
+ async function persist(document, operation, persisted) {
628
797
  const normalized = normalizeStateTypes(document);
629
798
  await runFieldValidators(normalized, operation);
630
- await writeJson(filePath, normalized, writeOptions);
799
+ const output = pruneUnknown === true ? normalized : withUnknownKeys(normalized, persisted, fields);
800
+ await writeJson(filePath, output, writeOptions);
631
801
  return normalized;
632
802
  }
633
- async function write(config) {
634
- return persist({ ...config }, "write");
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
+ });
635
811
  }
636
- async function update(updater) {
637
- return persist({ ...updater(await read()) }, "update");
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
+ });
638
819
  }
639
- async function patch(partial) {
640
- return persist({
641
- ...await readRaw(),
642
- ...partial
643
- }, "patch");
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
+ });
644
828
  }
645
- async function reset() {
646
- await deleteJson(filePath);
829
+ function reset() {
830
+ return mutate("delete", () => deleteJson(filePath));
647
831
  }
648
832
  return {
649
833
  read,
@@ -653,5 +837,4 @@ function createStore(options) {
653
837
  reset
654
838
  };
655
839
  }
656
- //#endregion
657
840
  export { CrustStoreError, cacheDir, configDir, createStore, dataDir, stateDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/store",
3
- "version": "0.4.4",
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,15 +40,15 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@crustjs/utils": "^0.2.2"
43
+ "@crustjs/utils": "^0.3.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@crustjs/config": "0.0.0",
47
- "zod": "^4.6.5",
48
- "vite-plus": "1.0.0"
47
+ "zod": "^4.6.7",
48
+ "vite-plus": "1.4.0"
49
49
  },
50
50
  "peerDependencies": {
51
- "typescript": "^7.0.0"
51
+ "typescript": ">=5.9.0"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "typescript": {
@@ -57,8 +57,7 @@
57
57
  },
58
58
  "engines": {
59
59
  "bun": ">=1.4.0",
60
- "node": ">=24",
61
- "deno": ">=2.8"
60
+ "node": ">=24"
62
61
  },
63
62
  "scripts": {
64
63
  "build": "vp run build:task",