@rezwanul7/settings 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,7 @@ A single-owner Scope, such as `global` for app-wide settings, has exactly one Ow
38
38
  import { defineSettings } from "@rezwanul7/settings";
39
39
  import { z } from "zod";
40
40
 
41
- export const user = defineSettings({
41
+ export const userDefinition = defineSettings({
42
42
  schema: z.object({
43
43
  theme: z.enum(["light", "dark", "system"]),
44
44
  language: z.string(),
@@ -51,7 +51,7 @@ export const user = defineSettings({
51
51
  },
52
52
  });
53
53
 
54
- export const global = defineSettings({
54
+ export const globalDefinition = defineSettings({
55
55
  schema: z.object({ maintenanceBanner: z.string().nullable() }),
56
56
  defaults: { maintenanceBanner: null },
57
57
  singleOwner: true,
@@ -76,33 +76,37 @@ the schema again breaks:
76
76
  - Don't use schema-level defaults, such as Zod's `.default()`. The Settings definition's `defaults` are the one place
77
77
  defaults live, and every field already has one there.
78
78
  - Use only JSON values: strings, finite numbers, booleans, `null`, arrays and plain objects. Every Store keeps the
79
- Overrides as JSON, so a write with anything else, such as a `Date`, a class instance, a `Map`, `NaN` or an array
80
- hole, throws `SettingsValidationError` with a `"Not a JSON value"` issue at its path, even where the schema accepts
81
- it. For a date, store the string and convert it where the setting is used.
79
+ Overrides as JSON. This is checked at compile time: `defineSettings` rejects a schema whose output has a `Date`, a
80
+ `Map`, a `Set`, a `bigint` or a class instance, such as `z.coerce.date()`, with an error at `schema` that names the
81
+ path. For a date, store the string and convert it where the setting is used. Where the output type is `unknown` or
82
+ `any`, or the caller is untyped, a write with a value that isn't JSON, such as a `Date`, `NaN` or an array hole,
83
+ throws `SettingsValidationError` with a `"Not a JSON value"` issue at its path, even where the schema accepts it.
82
84
 
83
85
  ## Creating the settings
84
86
 
85
87
  Pass every Settings definition, by Scope name, and a Store to `createSettings`. Then `scope(name, id)` returns one
86
- Owner's settings, a `ScopedSettings`: `scope("user", "42")` is user `42`, not every user. Owner ids are strings. A
87
- single-owner Scope takes no id; any other Scope requires one, and both rules are checked by the types.
88
+ Owner's settings, a `ScopedSettings`: `scope("user", "42")` is user `42`, not every user. Owner ids are non-empty
89
+ strings: `scope("user", "")` throws a `TypeError`, so callers whose session value is missing can't all share one
90
+ Owner. There are no other format rules, so `"42"` and `" 42"` are different Owners. A single-owner Scope takes no id;
91
+ any other Scope requires one, and both rules are checked by the types.
88
92
 
89
93
  The Scope name is stored with every Owner's Overrides. Renaming a Scope, such as `user` to `member`, leaves the stored
90
94
  rows under the old name, so its Owners read the defaults until the rows are migrated to the new name.
91
95
 
92
96
  ```ts
93
97
  import { createSettings, MemoryStore } from "@rezwanul7/settings";
94
- import { global, user } from "./settings-definitions";
98
+ import { globalDefinition, userDefinition } from "./settings-definitions";
95
99
 
96
100
  const settings = createSettings({
97
- definitions: { user, global },
101
+ definitions: { user: userDefinition, global: globalDefinition },
98
102
  store: new MemoryStore(),
99
103
  onInvalid: (owner, issues) => {
100
- console.warn("Ignored stale settings", owner, issues);
104
+ console.warn("Ignored stale Overrides", owner, issues);
101
105
  },
102
106
  maxRetries: 3,
103
107
  });
104
108
 
105
- const userSettings = settings.scope("user", "42");
109
+ const user42Settings = settings.scope("user", "42");
106
110
  const globalSettings = settings.scope("global");
107
111
  ```
108
112
 
@@ -123,17 +127,17 @@ aren't shared between processes. For a database, use the [Prisma Store](#storing
123
127
  ## Reading and writing an Owner's settings
124
128
 
125
129
  ```ts
126
- const me = settings.scope("user", "42");
130
+ const user42Settings = settings.scope("user", "42");
127
131
 
128
- await me.get(); // { theme: "system", language: "en", notifications: { email: true, push: false } }
129
- await me.get("notifications.email"); // true
132
+ await user42Settings.get(); // { theme: "system", language: "en", notifications: { email: true, push: false } }
133
+ await user42Settings.get("notifications.email"); // true
130
134
 
131
- await me.set("theme", "dark");
132
- await me.update({ language: "de", notifications: { push: true } });
133
- await me.getOverrides(); // { theme: "dark", language: "de", notifications: { push: true } }
135
+ await user42Settings.set("theme", "dark");
136
+ await user42Settings.update({ language: "de", notifications: { push: true } });
137
+ await user42Settings.getOverrides(); // { theme: "dark", language: "de", notifications: { push: true } }
134
138
 
135
- await me.reset("theme"); // theme follows the default again
136
- await me.reset(); // deletes everything stored for the Owner
139
+ await user42Settings.reset("theme"); // theme follows the default again
140
+ await user42Settings.reset(); // deletes everything stored for the Owner
137
141
  ```
138
142
 
139
143
  | Member | What it does |
@@ -165,6 +169,22 @@ await me.reset(); // deletes everything stored for the Owner
165
169
  - `getOverrides()` suits a settings page that marks which values are the Owner's own. It leaves out the Overrides a
166
170
  read would ignore, the same as `get()`, so the two always agree, and it is typed like the partial `update` takes.
167
171
 
172
+ ### Record keys with a dot
173
+
174
+ A record such as `labels: z.record(z.string(), z.number())` may have keys with a dot, such as `"example.com"`, but a
175
+ dot path can't reach them: each dot in a path goes one level deeper. Change such a key through the whole record
176
+ instead:
177
+
178
+ ```ts
179
+ await siteSettings.update({ labels: { "example.com": 1, other: 2 } });
180
+ await siteSettings.get("labels"); // { "example.com": 1, other: 2 }
181
+ await siteSettings.reset("labels.example.com"); // nothing is at labels → example → com, so this does nothing
182
+
183
+ const { "example.com": _, ...rest } = await siteSettings.get("labels");
184
+ await siteSettings.set("labels", rest); // replaces the record whole: { other: 2 }
185
+ await siteSettings.reset("labels"); // removes the record's Override, so the default applies again
186
+ ```
187
+
168
188
  ### Authorize the Owner first
169
189
 
170
190
  An Owner id is data, not a credential: the package stores and returns whatever Owner the app names, and checks
@@ -201,20 +221,20 @@ Each row holds one Owner's Overrides; an Owner with nothing stored has no row an
201
221
  names, the compound id on `scope` and `ownerId`, and the `version` default of `1`; the model and table names can be
202
222
  changed, as long as you pass the matching delegate.
203
223
 
204
- The row is removed by `reset()`, and a row created after it starts again at version 1. So a write that read the Owner
205
- just before a `reset()`, and stores after another write has recreated the row and brought it back to the version it
206
- read, overwrites that row: the Overrides written since the reset are lost. The model keeps no version once the row is
207
- gone, so the Store can't prevent that; call `reset()` when no other write to the Owner is expected, such as when
208
- deleting the Owner.
224
+ The row is removed by `reset()`, and the model keeps no version once the row is gone. A row created after it starts
225
+ at a random version rather than at 1, so a write that read the Owner just before a `reset()` is very unlikely to match
226
+ the new row's version and is retried as a conflict. It can't be ruled out: if the versions do match, that write
227
+ overwrites the new row and the Overrides written since the reset are lost. Call `reset()` when no other write to the
228
+ Owner is expected, such as when deleting the Owner.
209
229
 
210
230
  ```ts
211
231
  import { createSettings } from "@rezwanul7/settings";
212
232
  import { PrismaStore } from "@rezwanul7/settings/prisma";
213
233
  import { prisma } from "./db";
214
- import { global, user } from "./settings-definitions";
234
+ import { globalDefinition, userDefinition } from "./settings-definitions";
215
235
 
216
236
  export const settings = createSettings({
217
- definitions: { user, global },
237
+ definitions: { user: userDefinition, global: globalDefinition },
218
238
  store: new PrismaStore({ delegate: prisma.setting }),
219
239
  });
220
240
  ```
@@ -233,13 +253,13 @@ import { createSettings } from "@rezwanul7/settings";
233
253
  import { PrismaStore } from "@rezwanul7/settings/prisma";
234
254
  import { auth } from "./auth";
235
255
  import { prisma } from "./db";
236
- import { global, user } from "./settings-definitions";
256
+ import { globalDefinition, userDefinition } from "./settings-definitions";
237
257
 
238
258
  export const settings = createSettings({
239
- definitions: { user, global },
259
+ definitions: { user: userDefinition, global: globalDefinition },
240
260
  store: new PrismaStore({ delegate: prisma.setting }),
241
261
  onInvalid: (owner, issues) => {
242
- console.warn("Ignored stale settings", { owner, issues });
262
+ console.warn("Ignored stale Overrides", { owner, issues });
243
263
  },
244
264
  });
245
265
 
@@ -283,6 +303,8 @@ the Owner's page ([ADR-0001](./docs/adr/0001-overrides-only-ignored-when-invalid
283
303
  to ignore. The rest of the Owner's Overrides still apply.
284
304
  - `onInvalid(owner, issues)` is called with the issues naming what was ignored; an unknown key is reported as an issue
285
305
  with the message `"Unknown setting"`. Without `onInvalid`, they are ignored silently, so pass it to log them.
306
+ `onInvalid` isn't awaited, and a hook that throws or returns a rejected promise doesn't change the read or write: the
307
+ failure goes to `console.warn` once for each `createSettings` result.
286
308
  - `getOverrides()` ignores them the same way, but returns the Overrides it kept instead of throwing when the
287
309
  Effective settings still fail, so a page can show and fix them.
288
310
  - A read never changes the stored document. The next successful write for that Owner drops the ignored Overrides.
@@ -302,14 +324,16 @@ is unchanged. If another write got there first, it reads again and applies its c
302
324
  requests changing different settings of one Owner both take effect. After `maxRetries` retries that all lost the race,
303
325
  it throws `SettingsConflictError`, whose `owner` names the Owner, and stores nothing; it is safe to try the whole write
304
326
  again later. `reset()` deletes without a version check. `MemoryStore` keeps an Owner's versions increasing across
305
- `reset()`, so a write that read the Owner before the reset is a conflict; `PrismaStore` can't, see
306
- [Storing settings with Prisma](#storing-settings-with-prisma).
327
+ `reset()`, so a write that read the Owner before the reset is a conflict; with `PrismaStore` it is only very likely to
328
+ be one, see [Storing settings with Prisma](#storing-settings-with-prisma).
307
329
 
308
- ## Secret settings
330
+ ## Sealed settings
309
331
 
310
- Keep a secret setting, such as an SMTP password, sealed by wrapping your Store in `SealedStore` with a
311
- [`@rezwanul7/secret-vault`](../secret-vault/README.md) `Vault`. List the paths to seal; you call `set` and `get` with
312
- plain values, the schema validates the Plaintext, and the wrapped Store only ever holds a Sealed value there. This
332
+ Keep a setting that holds a secret, such as an SMTP password, sealed by wrapping your Store in `SealedStore` with a
333
+ [`@rezwanul7/secret-vault`](../secret-vault/README.md) `Vault`. Pass it the same `definitions` as `createSettings` and
334
+ list the paths to seal by Scope; each path is checked against its Scope's settings type, so a misspelled path or Scope
335
+ doesn't compile. You call `set` and `get` with plain values, the schema validates the Plaintext, and the wrapped Store
336
+ only ever holds a Sealed value there. This
313
337
  package doesn't depend on Secret Vault: `vault` only needs `encryptJson`, `decryptJson` and, optionally, `isSealed`
314
338
  (without it, a string starting with `vault:` counts as sealed, which is what a `Vault` writes).
315
339
 
@@ -324,7 +348,7 @@ import { z } from "zod";
324
348
  import { prisma } from "./db";
325
349
  import { vault } from "./vault";
326
350
 
327
- const organization = defineSettings({
351
+ const organizationDefinition = defineSettings({
328
352
  schema: z.object({
329
353
  smtp: z.object({
330
354
  host: z.string(),
@@ -334,27 +358,40 @@ const organization = defineSettings({
334
358
  defaults: { smtp: { host: "localhost", password: null } },
335
359
  });
336
360
 
361
+ const definitions = { organization: organizationDefinition };
362
+
337
363
  const settings = createSettings({
338
- definitions: { organization },
364
+ definitions,
339
365
  store: new SealedStore({
340
366
  store: new PrismaStore({ delegate: prisma.setting }),
341
367
  vault,
342
- paths: ["smtp.password"],
368
+ definitions,
369
+ paths: { organization: ["smtp.password"] },
343
370
  }),
344
371
  });
345
372
 
346
- await settings.scope("organization", orgId).set("smtp.password", "hunter2");
373
+ await settings
374
+ .scope("organization", organizationId)
375
+ .set("smtp.password", "hunter2");
347
376
  const password = await settings
348
- .scope("organization", orgId)
377
+ .scope("organization", organizationId)
349
378
  .get("smtp.password"); // "hunter2"
350
379
  ```
351
380
 
352
381
  - Each Sealed value is bound to its Owner and path (Context `settings:` plus `JSON.stringify([scope, id, path])`), so
353
382
  one copied to another Owner or path doesn't open. A script that moves a Sealed setting must open it and seal it
354
383
  again.
355
- - A path may name an object, which is sealed whole as JSON. No path may be inside another.
384
+ - Only the paths listed for an Owner's Scope are sealed: `smtp.password` in another Scope stays plain unless that Scope
385
+ lists it too. Two Scopes may list the same path.
386
+ - **A Scope left out of `paths` isn't sealed or opened.** If it already holds Sealed values, such as after you remove
387
+ it from `paths`, they read back as their raw `vault:…` strings. List every Scope that has a Sealed setting.
388
+ - A path may name an object, which is sealed whole as JSON. No path may be inside another of the same Scope. The
389
+ constructor throws `TypeError` for that, for an empty path, for a Scope that isn't in `definitions`, and when no
390
+ Scope lists a path.
356
391
  - A Sealed value that doesn't open, such as after a wrong Vault secret, makes the read **throw**. It is never ignored
357
- as a stale Override, because the next write would replace the secret with its default.
392
+ as a stale Override, because the next write would replace the secret with its default. Every other operation on
393
+ that Owner throws too, except `reset()`; to repair just that value, see
394
+ [Removing a Sealed value that won't open](#removing-a-sealed-value-that-wont-open).
358
395
  - Every write seals again with the Vault's current secret, so a Rotation finishes for an Owner the next time it is
359
396
  written.
360
397
  - Adding a path to `paths` on a deployment that already has plain values there is safe: they read as before and are
@@ -364,11 +401,38 @@ const password = await settings
364
401
  - Switching from values you sealed by hand: their Context differs, so open each with your old Context once and write
365
402
  the Plaintext back through the settings with a script; `SealedStore` then seals it under its own Context.
366
403
 
404
+ ### Removing a Sealed value that won't open
405
+
406
+ `discard(owner, path)` removes one Sealed value that won't open, so the Owner can read its settings and set a new
407
+ secret again. Its other Overrides stay as they are, and its other Sealed values stay sealed and aren't opened.
408
+
409
+ ```ts
410
+ const sealedStore = new SealedStore({ store, vault, definitions, paths });
411
+
412
+ const removed = await sealedStore.discard(
413
+ { scope: "organization", id: organizationId },
414
+ "smtp.password",
415
+ ); // true when it removed a value; the Owner now reads the default there
416
+ ```
417
+
418
+ **Check the Vault secret first.** A wrong or missing secret makes every Sealed value fail to open, so `discard` would
419
+ remove secrets that fixing the configuration would have recovered. Only discard once you know the right secret is
420
+ loaded and this one value still doesn't open, such as one sealed under a secret that was since removed.
421
+
422
+ - It returns `false` and changes nothing when the value opens, isn't a Sealed value, or isn't there, so running the
423
+ same repair again after the Owner set a new secret doesn't destroy it.
424
+ - It throws `TypeError` for a path not listed for the Owner's Scope. It takes one Owner and one path; there is no bulk
425
+ form.
426
+ - It writes with the version it read; on a conflict it reads again and retries up to 3 times, then throws
427
+ `SettingsConflictError`. A `CachedStore` underneath evicts the Owner on the conflict, so the retry reads fresh.
428
+
367
429
  ## Caching reads
368
430
 
369
431
  `CachedStore` keeps what the Store it wraps returns for `ttl` seconds, so reading one Owner's settings in several
370
432
  requests doesn't query the database each time. An Owner with nothing stored is cached too. Every write, conflict and
371
- delete evicts the Owner, so a write on this instance is read back at once and two concurrent writes both take effect.
433
+ delete evicts the Owner, so two concurrent writes both take effect, and a write on this instance is read back at once by
434
+ this instance, even when one of its reads of that Owner was in flight during the write: a read that an eviction
435
+ overtook returns what it read but isn't cached.
372
436
 
373
437
  ```ts
374
438
  import { CachedStore, MemoryCache, SealedStore } from "@rezwanul7/settings";
@@ -380,15 +444,20 @@ const store = new SealedStore({
380
444
  ttl: 60,
381
445
  }),
382
446
  vault,
383
- paths: ["smtp.password"],
447
+ definitions,
448
+ paths: { organization: ["smtp.password"] },
384
449
  });
385
450
  ```
386
451
 
387
452
  Nest `SealedStore` outermost so the cache only holds Sealed values. A cache that throws is passed to the optional
388
- `onError` and the Store answers instead; a failing `delete` leaves the entry to expire with its TTL.
453
+ `onError` and the Store answers instead; a failing `delete` leaves the entry to expire with its TTL. `onError` isn't
454
+ awaited, and one that throws or returns a rejected promise doesn't change the operation, so a write still returns the
455
+ wrapped Store's result; the failure goes to `console.warn` once for each `CachedStore`.
389
456
 
390
457
  `MemoryCache` is per process. With several instances, another instance can serve Overrides up to `ttl` seconds old
391
- after a write elsewhere. Share a cache to avoid that; any object with three string methods fits `SettingsCache`:
458
+ after a write elsewhere. Share a cache to avoid most of that. Even with a shared cache, a read on another instance that
459
+ was in flight during the write can still cache the old Overrides, which are then read for up to `ttl` seconds. Any
460
+ object with three string methods fits `SettingsCache`:
392
461
 
393
462
  ```ts
394
463
  import type { SettingsCache } from "@rezwanul7/settings";
@@ -415,8 +484,8 @@ const redisCache: SettingsCache = {
415
484
  Effective settings still fail it after ignoring stale Overrides. It is a bug in the app's Settings definitions, not
416
485
  in the input, so don't show it to a user. It carries the `scope` and, for a failed validation, the `issues`.
417
486
 
418
- `createSettings` also throws a `RangeError` for an invalid `maxRetries`, and `scope()` throws a `TypeError` for an id
419
- passed to a single-owner Scope or missing for any other, which the types already rule out.
487
+ `createSettings` also throws a `RangeError` for an invalid `maxRetries`, and `scope()` throws a `TypeError` for an
488
+ empty id, and for an id passed to a single-owner Scope or missing for any other, which the types already rule out.
420
489
 
421
490
  ```ts
422
491
  import { SettingsValidationError } from "@rezwanul7/settings";
package/dist/index.cjs CHANGED
@@ -249,10 +249,39 @@ function coveringOverride(overrides, keys) {
249
249
  return [...keys];
250
250
  }
251
251
 
252
+ // src/warn-once.ts
253
+ function warnOnce(message) {
254
+ let warned = false;
255
+ return (failure) => {
256
+ if (warned) return;
257
+ warned = true;
258
+ console.warn(message, failure);
259
+ };
260
+ }
261
+ function guardHook(hook, message) {
262
+ if (hook === void 0) return void 0;
263
+ const warn = warnOnce(message);
264
+ return (...args) => {
265
+ try {
266
+ const result = hook(...args);
267
+ if (isThenable(result)) Promise.resolve(result).catch(warn);
268
+ } catch (error) {
269
+ warn(error);
270
+ }
271
+ };
272
+ }
273
+ function isThenable(value) {
274
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
275
+ }
276
+
252
277
  // src/settings.ts
253
278
  var SINGLE_OWNER_ID = "default";
254
279
  function createSettings(options) {
255
- const { definitions, store, onInvalid, maxRetries = 3 } = options;
280
+ const { definitions, store, maxRetries = 3 } = options;
281
+ const onInvalid = guardHook(
282
+ options.onInvalid,
283
+ "@rezwanul7/settings: onInvalid failed, and the operation went on without it. Later failures of this onInvalid aren't reported."
284
+ );
256
285
  if (!Number.isInteger(maxRetries) || maxRetries < 0) {
257
286
  throw new RangeError(
258
287
  `maxRetries must be a whole number, 0 or more; got ${maxRetries}.`
@@ -417,6 +446,9 @@ function ownerOf(scope, definition, id) {
417
446
  if (typeof id !== "string") {
418
447
  throw new TypeError(`Scope "${scope}" needs an Owner id.`);
419
448
  }
449
+ if (id === "") {
450
+ throw new TypeError(`Scope "${scope}" needs a non-empty Owner id.`);
451
+ }
420
452
  return { scope, id };
421
453
  }
422
454
  function rejectForbiddenKeys(path, value) {
@@ -470,6 +502,7 @@ function asJson(overrides) {
470
502
  }
471
503
 
472
504
  // src/sealed-store.ts
505
+ var DISCARD_RETRIES = 3;
473
506
  function defaultIsSealed(value) {
474
507
  return typeof value === "string" && value.startsWith("vault:");
475
508
  }
@@ -481,22 +514,46 @@ var SealedStore = class {
481
514
  #vault;
482
515
  #paths;
483
516
  /**
484
- * @param options - The Store to wrap, the Vault and the sealed paths.
485
- * @throws `TypeError` when a path is empty or inside another path.
517
+ * @param options - The Store to wrap, the Vault, the Settings definitions and the sealed paths by Scope.
518
+ * @throws `TypeError` when no Scope lists a path, a Scope in `paths` isn't in `definitions`, a path is empty, or a
519
+ * path is inside another of the same Scope.
486
520
  */
487
521
  constructor(options) {
488
522
  this.#store = options.store;
489
523
  this.#vault = options.vault;
490
- this.#paths = [...new Set(options.paths)];
491
- for (const path of this.#paths) {
492
- if (path === "") throw new TypeError("SealedStore: a path is empty.");
493
- const inner = this.#paths.find((p) => p.startsWith(`${path}.`));
494
- if (inner !== void 0) {
524
+ const paths = /* @__PURE__ */ new Map();
525
+ for (const [scope, list] of Object.entries(
526
+ options.paths
527
+ )) {
528
+ if (!Object.hasOwn(options.definitions, scope)) {
495
529
  throw new TypeError(
496
- `SealedStore: path "${inner}" is inside path "${path}".`
530
+ `SealedStore: Scope "${scope}" in paths isn't in definitions.`
497
531
  );
498
532
  }
533
+ const unique = [...new Set(list ?? [])];
534
+ for (const path of unique) {
535
+ if (path === "") {
536
+ throw new TypeError(
537
+ `SealedStore: a path of Scope "${scope}" is empty.`
538
+ );
539
+ }
540
+ const inner = unique.find((p) => p.startsWith(`${path}.`));
541
+ if (inner !== void 0) {
542
+ throw new TypeError(
543
+ `SealedStore: path "${inner}" is inside path "${path}" in Scope "${scope}".`
544
+ );
545
+ }
546
+ }
547
+ if (unique.length > 0) paths.set(scope, unique);
548
+ }
549
+ if (paths.size === 0) {
550
+ throw new TypeError("SealedStore: no Scope lists a path to seal.");
499
551
  }
552
+ this.#paths = paths;
553
+ }
554
+ /** The paths sealed for the Owner's Scope. */
555
+ #pathsOf(owner) {
556
+ return this.#paths.get(owner.scope) ?? [];
500
557
  }
501
558
  /** {@inheritDoc SettingsStore.read} */
502
559
  async read(owner) {
@@ -504,7 +561,7 @@ var SealedStore = class {
504
561
  if (stored === null) return null;
505
562
  const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
506
563
  let overrides = stored.overrides;
507
- for (const path of this.#paths) {
564
+ for (const path of this.#pathsOf(owner)) {
508
565
  const value = getAt(overrides, path);
509
566
  if (!isSealed(value)) continue;
510
567
  const plaintext = await this.#vault.decryptJson(
@@ -517,18 +574,74 @@ var SealedStore = class {
517
574
  }
518
575
  /** {@inheritDoc SettingsStore.write} */
519
576
  async write(owner, overrides, expectedVersion) {
577
+ return this.#store.write(
578
+ owner,
579
+ await this.#seal(owner, overrides, () => false),
580
+ expectedVersion
581
+ );
582
+ }
583
+ /**
584
+ * Seals the values at the paths listed for the Owner's Scope, skipping those `skip` matches; `discard` skips the
585
+ * values already sealed, so it never opens or seals them again.
586
+ */
587
+ async #seal(owner, overrides, skip) {
520
588
  let sealed = overrides;
521
- for (const path of this.#paths) {
589
+ for (const path of this.#pathsOf(owner)) {
522
590
  if (!hasAt(sealed, path)) continue;
523
591
  const value = getAt(sealed, path);
524
- if (value === void 0) continue;
592
+ if (value === void 0 || skip(value)) continue;
525
593
  sealed = setAt(
526
594
  sealed,
527
595
  path,
528
596
  await this.#vault.encryptJson(value, contextOf(owner, path))
529
597
  );
530
598
  }
531
- return this.#store.write(owner, sealed, expectedVersion);
599
+ return sealed;
600
+ }
601
+ /**
602
+ * Removes one Sealed value that won't open, so the Owner can read its settings and set a new secret again; its
603
+ * other Sealed values stay sealed and aren't opened, and a plain value at another listed path is sealed, as on any
604
+ * write. Its other Overrides stay as they are. A value that opens, a
605
+ * plain value and an empty path are left alone, so running it again after the Owner set a new secret changes
606
+ * nothing. Any rejection from `decryptJson` counts as not opening.
607
+ *
608
+ * **Check the Vault secret first.** With a wrong or missing secret every Sealed value fails to open, so `discard`
609
+ * would remove secrets that fixing the configuration would have recovered.
610
+ *
611
+ * It reads the wrapped Store and writes with the version it read. On a conflict it reads again and retries, up to 3
612
+ * times; a {@link CachedStore} underneath evicts on the conflict, so the retry reads fresh Overrides.
613
+ *
614
+ * @param owner - The Owner whose Sealed value to remove.
615
+ * @param path - A path listed for the Owner's Scope, such as `"smtp.password"`.
616
+ * @returns `true` when a Sealed value that won't open was removed, `false` when nothing was changed.
617
+ * @throws `TypeError` when `path` isn't listed for the Owner's Scope.
618
+ * @throws {@link SettingsConflictError} when every write lost the version race to another write.
619
+ */
620
+ async discard(owner, path) {
621
+ if (!this.#pathsOf(owner).includes(path)) {
622
+ throw new TypeError(
623
+ `SealedStore: path "${path}" isn't sealed in Scope "${owner.scope}".`
624
+ );
625
+ }
626
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
627
+ for (let attempt = 0; attempt <= DISCARD_RETRIES; attempt++) {
628
+ const stored = await this.#store.read(owner);
629
+ if (stored === null || !hasAt(stored.overrides, path)) return false;
630
+ const value = getAt(stored.overrides, path);
631
+ if (!isSealed(value)) return false;
632
+ const opens = await this.#vault.decryptJson(value, contextOf(owner, path)).then(
633
+ () => true,
634
+ () => false
635
+ );
636
+ if (opens) return false;
637
+ const version = await this.#store.write(
638
+ owner,
639
+ await this.#seal(owner, removeAt(stored.overrides, path), isSealed),
640
+ stored.version
641
+ );
642
+ if (version !== null) return true;
643
+ }
644
+ throw new SettingsConflictError(owner, DISCARD_RETRIES);
532
645
  }
533
646
  /** {@inheritDoc SettingsStore.delete} */
534
647
  async delete(owner) {
@@ -543,6 +656,12 @@ var CachedStore = class {
543
656
  #ttl;
544
657
  #prefix;
545
658
  #onError;
659
+ /**
660
+ * The Owners with a read of the wrapped Store in flight, by cache key: how many such reads there are, and how many
661
+ * evictions happened since the first began. A read caches only if no eviction happened during it. An entry goes
662
+ * when its last read finishes, so this holds only the Owners being read right now.
663
+ */
664
+ #inFlight = /* @__PURE__ */ new Map();
546
665
  /**
547
666
  * @param options - The Store to wrap, the cache and the TTL.
548
667
  * @throws `RangeError` when `ttl` isn't a positive number.
@@ -555,7 +674,10 @@ var CachedStore = class {
555
674
  this.#cache = options.cache;
556
675
  this.#ttl = options.ttl;
557
676
  this.#prefix = options.prefix ?? "settings";
558
- this.#onError = options.onError;
677
+ this.#onError = guardHook(
678
+ options.onError,
679
+ "@rezwanul7/settings: CachedStore onError failed, and the operation went on without it. Later failures of this onError aren't reported."
680
+ );
559
681
  }
560
682
  /** {@inheritDoc SettingsStore.read} */
561
683
  async read(owner) {
@@ -568,13 +690,33 @@ var CachedStore = class {
568
690
  } catch (error) {
569
691
  this.#report(error);
570
692
  }
571
- const stored = await this.#store.read(owner);
693
+ let reading = this.#inFlight.get(key2);
694
+ if (reading === void 0) {
695
+ reading = { reads: 0, evictions: 0 };
696
+ this.#inFlight.set(key2, reading);
697
+ }
698
+ reading.reads++;
699
+ const evictionsBefore = reading.evictions;
572
700
  try {
573
- await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
574
- } catch (error) {
575
- this.#report(error);
701
+ const stored = await this.#store.read(owner);
702
+ if (reading.evictions === evictionsBefore) {
703
+ try {
704
+ await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
705
+ } catch (error) {
706
+ this.#report(error);
707
+ }
708
+ if (reading.evictions !== evictionsBefore) {
709
+ try {
710
+ await this.#cache.delete(key2);
711
+ } catch (error) {
712
+ this.#report(error);
713
+ }
714
+ }
715
+ }
716
+ return stored;
717
+ } finally {
718
+ if (--reading.reads === 0) this.#inFlight.delete(key2);
576
719
  }
577
- return stored;
578
720
  }
579
721
  /** {@inheritDoc SettingsStore.write} */
580
722
  async write(owner, overrides, expectedVersion) {
@@ -593,8 +735,11 @@ var CachedStore = class {
593
735
  }
594
736
  }
595
737
  async #evict(owner) {
738
+ const key2 = this.#key(owner);
739
+ const reading = this.#inFlight.get(key2);
740
+ if (reading !== void 0) reading.evictions++;
596
741
  try {
597
- await this.#cache.delete(this.#key(owner));
742
+ await this.#cache.delete(key2);
598
743
  } catch (error) {
599
744
  this.#report(error);
600
745
  }