@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 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-compatible data (no Date, Map, bigint, or functions)";
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 defaults.
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
- * When `true` (the default), persisted keys not present in `fields`
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
- * @default true
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
- * Mutations are not serialized, including overlapping calls in one process.
262
- * 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.
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 final file replacement is atomic, but the read-modify-write sequence
319
- * is not serialized, even within one process. Await mutations sequentially
320
- * 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.
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. Await mutations sequentially to avoid lost updates.
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 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.
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 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.
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 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.
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 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.
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, 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
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
- if (env.platform === "linux" || env.platform === "darwin") {
120
- const configured = env.env[config.xdg];
121
- const base = configured && posix.isAbsolute(configured) ? configured : join(env.homedir, ...config.unixFallback);
122
- return join(base, appName);
123
- }
124
- if (env.platform === "win32") {
125
- const configured = env.env[config.windowsEnv];
126
- const base = configured?.trim() ? configured : join(env.homedir, ...config.windowsFallback);
127
- return join(base, appName, ...config.windowsSuffix);
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 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.
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 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.
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 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.
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 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.
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 config object.
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
- * When `pruneUnknown` is `true` (the default), keys in `persisted` that are
284
- * 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}.
285
289
  *
286
- * @param persisted - Parsed JSON from disk, or `undefined` if no file exists.
287
- * @param fields - Store field definitions.
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, pruneUnknown = true) {
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
- if (!pruneUnknown && persisted !== void 0) {
296
- for (const [key, value] of Object.entries(persisted)) if (!Object.hasOwn(fields, key)) setDocumentValue(result, key, value);
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 config file from disk.
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 config file.
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 config file: ${filePath}`, {
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 config file: ${filePath}`, { path: filePath }, err);
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
- * Atomically writes a JSON value to disk using a temp-file-and-rename strategy.
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 writeJson(filePath, data, options = {}) {
347
- const isWindows = process.platform === "win32";
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 config directory: ${dir}`, {
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 config directory: ${dir}`, {
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 writeFile(tempPath, json, {
375
- encoding: "utf-8",
376
- 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;
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 config file: ${filePath}`, {
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 rename(tempPath, filePath);
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 config file: ${filePath}`, {
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
- * 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.
398
460
  *
399
461
  * Silently succeeds when the file does not exist (ENOENT) — deleting an
400
- * already-absent config is not an error.
462
+ * already-absent file is not an error.
401
463
  *
402
- * @param filePath - Absolute path to the JSON config file to delete.
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 config file: ${filePath}`, {
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 readRaw() {
775
+ async function readPersisted() {
613
776
  const persisted = await readJson(filePath);
614
- const persistedObject = persisted !== void 0 && isJsonObject(persisted) ? persisted : void 0;
615
- if (persisted !== void 0 && persistedObject === void 0) throw new CrustStoreError("PARSE", `Expected a JSON object in config file: ${filePath}`, { path: filePath });
616
- 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
+ }));
617
790
  }
618
791
  async function read() {
619
- const document = await readRaw();
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
- await writeJson(filePath, normalized, writeOptions);
799
+ const output = pruneUnknown === true ? normalized : withUnknownKeys(normalized, persisted, fields);
800
+ await writeJson(filePath, output, writeOptions);
627
801
  return normalized;
628
802
  }
629
- async function write(config) {
630
- 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
+ });
631
811
  }
632
- async function update(updater) {
633
- 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
+ });
634
819
  }
635
- async function patch(partial) {
636
- return persist({
637
- ...await readRaw(),
638
- ...partial
639
- }, "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
+ });
640
828
  }
641
- async function reset() {
642
- await deleteJson(filePath);
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.0",
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.0"
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": "^7.0.0"
51
+ "typescript": ">=5.9.0"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "typescript": {