@rezwanul7/settings 0.1.0 → 1.1.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
@@ -76,15 +76,18 @@ 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 `Date` would read back as its ISO string and an `undefined` value would be dropped. For a
80
- date, store the string and convert it where the setting is used. `MemoryStore` keeps them as JSON too, so tests
81
- show what a database does.
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.
82
82
 
83
83
  ## Creating the settings
84
84
 
85
- Pass every Settings definition, by Scope name, and a Store to `createSettings`. Then `scope(name, id)` gives one
86
- Owner's `ScopedSettings`. Owner ids are strings. A single-owner Scope takes no id; any other Scope requires one, and
87
- both rules are checked by the types.
85
+ 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
+
89
+ The Scope name is stored with every Owner's Overrides. Renaming a Scope, such as `user` to `member`, leaves the stored
90
+ rows under the old name, so its Owners read the defaults until the rows are migrated to the new name.
88
91
 
89
92
  ```ts
90
93
  import { createSettings, MemoryStore } from "@rezwanul7/settings";
@@ -140,7 +143,7 @@ await me.reset(); // deletes everything stored for the Owner
140
143
  | `get(path)` | One typed value of the Effective settings, at a dot path such as `"notifications.email"`. |
141
144
  | `set(path, value)` | Stores `value` as the Override at `path`, keeping the other Overrides. |
142
145
  | `update(partial)` | Deep-merges `partial` into the Overrides, to change several settings at once. |
143
- | `getOverrides()` | The Overrides as stored, without the defaults, or `{}` when nothing is stored. |
146
+ | `getOverrides()` | The Owner's own Overrides, without the defaults, or `{}` when nothing is stored. |
144
147
  | `reset(path)` | Removes the Override at `path` (and every Override under it), so the default applies again. |
145
148
  | `reset()` | Deletes everything stored for the Owner, so it reads the defaults. |
146
149
 
@@ -151,15 +154,16 @@ await me.reset(); // deletes everything stored for the Owner
151
154
  issue, and `get(path)` reads one as `undefined`. Stored ones are ignored as unknown keys.
152
155
  - Merging works the same on reads and in `update`: plain objects merge key by key; arrays, scalars and `null` replace.
153
156
  - In `update`, an `undefined` value leaves that Override as it was, and `null` is stored as a value, which passes only
154
- if the schema allows it.
157
+ if the schema allows it. `set(path, undefined)` throws `SettingsValidationError`; use `reset(path)` to remove an
158
+ Override.
155
159
  - A value equal to the current default is still stored as an Override, so a later change of the default doesn't change
156
160
  what the Owner chose. `reset(path)` is how an Owner follows the default again; with no Override at `path`, it
157
161
  writes nothing.
158
162
  - A write whose Effective settings fail the schema throws `SettingsValidationError` and stores nothing. So does a
159
163
  write with a key the schema doesn't know, which a schema that strips unknown keys would otherwise drop silently; the
160
164
  issue for it has the message `"Unknown setting"`, as on read.
161
- - `getOverrides()` suits a settings page that marks which values are the Owner's own; it returns the document as
162
- stored, including any Overrides a read would ignore.
165
+ - `getOverrides()` suits a settings page that marks which values are the Owner's own. It leaves out the Overrides a
166
+ read would ignore, the same as `get()`, so the two always agree, and it is typed like the partial `update` takes.
163
167
 
164
168
  ### Authorize the Owner first
165
169
 
@@ -279,6 +283,8 @@ the Owner's page ([ADR-0001](./docs/adr/0001-overrides-only-ignored-when-invalid
279
283
  to ignore. The rest of the Owner's Overrides still apply.
280
284
  - `onInvalid(owner, issues)` is called with the issues naming what was ignored; an unknown key is reported as an issue
281
285
  with the message `"Unknown setting"`. Without `onInvalid`, they are ignored silently, so pass it to log them.
286
+ - `getOverrides()` ignores them the same way, but returns the Overrides it kept instead of throwing when the
287
+ Effective settings still fail, so a page can show and fix them.
282
288
  - A read never changes the stored document. The next successful write for that Owner drops the ignored Overrides.
283
289
  - Writes read the Overrides first, so `onInvalid` can also be called during `set`, `update` or `reset(path)`, and more
284
290
  than once for one write when it retries after a conflict.
@@ -301,15 +307,17 @@ again later. `reset()` deletes without a version check. `MemoryStore` keeps an O
301
307
 
302
308
  ## Secret settings
303
309
 
304
- The package doesn't encrypt anything. Keep a secret setting, such as an SMTP password, sealed with
305
- [`@rezwanul7/secret-vault`](../secret-vault/README.md): seal it before `set()`, store the Sealed value as a string,
306
- and open it after `get()`. Give the Context the Owner, so a Sealed value copied to another Owner doesn't open.
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
313
+ package doesn't depend on Secret Vault: `vault` only needs `encryptJson`, `decryptJson` and, optionally, `isSealed`
314
+ (without it, a string starting with `vault:` counts as sealed, which is what a `Vault` writes).
307
315
 
308
316
  ```ts
309
317
  import {
310
318
  createSettings,
311
319
  defineSettings,
312
- type Owner,
320
+ SealedStore,
313
321
  } from "@rezwanul7/settings";
314
322
  import { PrismaStore } from "@rezwanul7/settings/prisma";
315
323
  import { z } from "zod";
@@ -320,8 +328,7 @@ const organization = defineSettings({
320
328
  schema: z.object({
321
329
  smtp: z.object({
322
330
  host: z.string(),
323
- // A Sealed value made by the Vault, never the password itself.
324
- password: z.string().startsWith("vault:").nullable(),
331
+ password: z.string().min(1).nullable(),
325
332
  }),
326
333
  }),
327
334
  defaults: { smtp: { host: "localhost", password: null } },
@@ -329,30 +336,74 @@ const organization = defineSettings({
329
336
 
330
337
  const settings = createSettings({
331
338
  definitions: { organization },
332
- store: new PrismaStore({ delegate: prisma.setting }),
339
+ store: new SealedStore({
340
+ store: new PrismaStore({ delegate: prisma.setting }),
341
+ vault,
342
+ paths: ["smtp.password"],
343
+ }),
333
344
  });
334
345
 
335
- function smtpPasswordContext(owner: Owner): string {
336
- return `settings:smtp.password:${owner.scope}:${owner.id}`;
337
- }
346
+ await settings.scope("organization", orgId).set("smtp.password", "hunter2");
347
+ const password = await settings
348
+ .scope("organization", orgId)
349
+ .get("smtp.password"); // "hunter2"
350
+ ```
338
351
 
339
- export async function saveSmtpPassword(orgId: string, password: string) {
340
- const org = settings.scope("organization", orgId);
341
- const sealed = await vault.encrypt(password, smtpPasswordContext(org.owner));
342
- await org.set("smtp.password", sealed);
343
- }
352
+ - Each Sealed value is bound to its Owner and path (Context `settings:` plus `JSON.stringify([scope, id, path])`), so
353
+ one copied to another Owner or path doesn't open. A script that moves a Sealed setting must open it and seal it
354
+ again.
355
+ - A path may name an object, which is sealed whole as JSON. No path may be inside another.
356
+ - 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.
358
+ - Every write seals again with the Vault's current secret, so a Rotation finishes for an Owner the next time it is
359
+ written.
360
+ - Adding a path to `paths` on a deployment that already has plain values there is safe: they read as before and are
361
+ sealed on each Owner's next write. Values at unlisted paths are never opened.
362
+ - The Effective settings now hold the **Plaintext**. Don't log them or pass them to the client; read the secret on the
363
+ server, only where it is used.
364
+ - Switching from values you sealed by hand: their Context differs, so open each with your old Context once and write
365
+ the Plaintext back through the settings with a script; `SealedStore` then seals it under its own Context.
366
+
367
+ ## Caching reads
368
+
369
+ `CachedStore` keeps what the Store it wraps returns for `ttl` seconds, so reading one Owner's settings in several
370
+ 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.
344
372
 
345
- export async function readSmtpPassword(orgId: string): Promise<string | null> {
346
- const org = settings.scope("organization", orgId);
347
- const sealed = await org.get("smtp.password");
348
- return sealed === null
349
- ? null
350
- : vault.decrypt(sealed, smtpPasswordContext(org.owner));
351
- }
373
+ ```ts
374
+ import { CachedStore, MemoryCache, SealedStore } from "@rezwanul7/settings";
375
+
376
+ const store = new SealedStore({
377
+ store: new CachedStore({
378
+ store: new PrismaStore({ delegate: prisma.setting }),
379
+ cache: new MemoryCache({ maxEntries: 5000 }),
380
+ ttl: 60,
381
+ }),
382
+ vault,
383
+ paths: ["smtp.password"],
384
+ });
352
385
  ```
353
386
 
354
- The Effective settings then hold only the Sealed value, so they can be logged or passed around without exposing the
355
- secret; open it on the server, only where it is used.
387
+ 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.
389
+
390
+ `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`:
392
+
393
+ ```ts
394
+ import type { SettingsCache } from "@rezwanul7/settings";
395
+ import { redis } from "./redis";
396
+
397
+ const redisCache: SettingsCache = {
398
+ get: async (key) => (await redis.get(key)) ?? undefined,
399
+ set: async (key, value, ttlSeconds) => {
400
+ await redis.set(key, value, "EX", Math.ceil(ttlSeconds));
401
+ },
402
+ delete: async (key) => {
403
+ await redis.del(key);
404
+ },
405
+ };
406
+ ```
356
407
 
357
408
  ## Errors
358
409
 
@@ -380,6 +431,10 @@ try {
380
431
  }
381
432
  ```
382
433
 
434
+ Each error sets `name` to its class name. When the app may load both the ESM and the CommonJS build of the package,
435
+ as mixed bundling can, `instanceof` fails across the two copies; check `error.name === "SettingsValidationError"`
436
+ there instead.
437
+
383
438
  ## API reference
384
439
 
385
440
  Every export, parameter, and default is listed in the [API reference](./docs/api/README.md), generated from the source.
package/dist/index.cjs CHANGED
@@ -140,6 +140,25 @@ function forbiddenKeysIn(value, path) {
140
140
  ([key2, child]) => FORBIDDEN_KEYS.has(key2) ? [{ message: "Forbidden key", path: [...path, key2] }] : forbiddenKeysIn(child, [...path, key2])
141
141
  );
142
142
  }
143
+ function nonJsonValues(path, value) {
144
+ return nonJsonValuesIn(value, path === void 0 ? [] : keysOf(path));
145
+ }
146
+ function nonJsonValuesIn(value, path) {
147
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
148
+ return [];
149
+ }
150
+ if (Array.isArray(value)) {
151
+ return Array.from(value).flatMap(
152
+ (item, index) => nonJsonValuesIn(item, [...path, index])
153
+ );
154
+ }
155
+ if (isPlainObject(value) && Object.getOwnPropertySymbols(value).length === 0) {
156
+ return Object.entries(value).flatMap(
157
+ ([key2, child]) => child === void 0 ? [] : nonJsonValuesIn(child, [...path, key2])
158
+ );
159
+ }
160
+ return [{ message: "Not a JSON value", path: [...path] }];
161
+ }
143
162
  function getAt(value, path) {
144
163
  let current = value;
145
164
  for (const key2 of keysOf(path)) {
@@ -349,16 +368,27 @@ function createSettings(options) {
349
368
  },
350
369
  async set(path, value) {
351
370
  rejectForbiddenKeys(path, value);
371
+ if (value === void 0) {
372
+ throw new SettingsValidationError([
373
+ {
374
+ message: "Not a JSON value: use reset(path) to remove an Override",
375
+ path: path.split(".")
376
+ }
377
+ ]);
378
+ }
379
+ rejectNonJsonValues(path, value);
352
380
  await change((overrides) => setAt(overrides, path, value));
353
381
  },
354
382
  async update(partial) {
355
383
  rejectForbiddenKeys(void 0, partial);
384
+ rejectNonJsonValues(void 0, partial);
356
385
  await change(
357
386
  (overrides) => mergeOverrides(overrides, withoutUndefined(partial))
358
387
  );
359
388
  },
360
389
  async getOverrides() {
361
- return (await readOverrides())?.overrides ?? {};
390
+ const stored = await readOverrides();
391
+ return (await withoutStale(stored?.overrides ?? {})).overrides;
362
392
  },
363
393
  async reset(path) {
364
394
  if (path === void 0) {
@@ -393,6 +423,10 @@ function rejectForbiddenKeys(path, value) {
393
423
  const issues = forbiddenKeys(path, value);
394
424
  if (issues.length > 0) throw new SettingsValidationError(issues);
395
425
  }
426
+ function rejectNonJsonValues(path, value) {
427
+ const issues = nonJsonValues(path, value);
428
+ if (issues.length > 0) throw new SettingsValidationError(issues);
429
+ }
396
430
  function unknownSettings(overrides, output) {
397
431
  return unknownKeys(overrides, output).map((path) => ({
398
432
  message: "Unknown setting",
@@ -435,7 +469,193 @@ function asJson(overrides) {
435
469
  return JSON.parse(JSON.stringify(overrides));
436
470
  }
437
471
 
472
+ // src/sealed-store.ts
473
+ function defaultIsSealed(value) {
474
+ return typeof value === "string" && value.startsWith("vault:");
475
+ }
476
+ function contextOf(owner, path) {
477
+ return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
478
+ }
479
+ var SealedStore = class {
480
+ #store;
481
+ #vault;
482
+ #paths;
483
+ /**
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.
486
+ */
487
+ constructor(options) {
488
+ this.#store = options.store;
489
+ 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) {
495
+ throw new TypeError(
496
+ `SealedStore: path "${inner}" is inside path "${path}".`
497
+ );
498
+ }
499
+ }
500
+ }
501
+ /** {@inheritDoc SettingsStore.read} */
502
+ async read(owner) {
503
+ const stored = await this.#store.read(owner);
504
+ if (stored === null) return null;
505
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
506
+ let overrides = stored.overrides;
507
+ for (const path of this.#paths) {
508
+ const value = getAt(overrides, path);
509
+ if (!isSealed(value)) continue;
510
+ const plaintext = await this.#vault.decryptJson(
511
+ value,
512
+ contextOf(owner, path)
513
+ );
514
+ overrides = setAt(overrides, path, plaintext);
515
+ }
516
+ return { overrides, version: stored.version };
517
+ }
518
+ /** {@inheritDoc SettingsStore.write} */
519
+ async write(owner, overrides, expectedVersion) {
520
+ let sealed = overrides;
521
+ for (const path of this.#paths) {
522
+ if (!hasAt(sealed, path)) continue;
523
+ const value = getAt(sealed, path);
524
+ if (value === void 0) continue;
525
+ sealed = setAt(
526
+ sealed,
527
+ path,
528
+ await this.#vault.encryptJson(value, contextOf(owner, path))
529
+ );
530
+ }
531
+ return this.#store.write(owner, sealed, expectedVersion);
532
+ }
533
+ /** {@inheritDoc SettingsStore.delete} */
534
+ async delete(owner) {
535
+ await this.#store.delete(owner);
536
+ }
537
+ };
538
+
539
+ // src/cached-store.ts
540
+ var CachedStore = class {
541
+ #store;
542
+ #cache;
543
+ #ttl;
544
+ #prefix;
545
+ #onError;
546
+ /**
547
+ * @param options - The Store to wrap, the cache and the TTL.
548
+ * @throws `RangeError` when `ttl` isn't a positive number.
549
+ */
550
+ constructor(options) {
551
+ if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
552
+ throw new RangeError("CachedStore: ttl must be a positive number.");
553
+ }
554
+ this.#store = options.store;
555
+ this.#cache = options.cache;
556
+ this.#ttl = options.ttl;
557
+ this.#prefix = options.prefix ?? "settings";
558
+ this.#onError = options.onError;
559
+ }
560
+ /** {@inheritDoc SettingsStore.read} */
561
+ async read(owner) {
562
+ const key2 = this.#key(owner);
563
+ try {
564
+ const cached = await this.#cache.get(key2);
565
+ if (cached !== void 0) {
566
+ return JSON.parse(cached);
567
+ }
568
+ } catch (error) {
569
+ this.#report(error);
570
+ }
571
+ const stored = await this.#store.read(owner);
572
+ try {
573
+ await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
574
+ } catch (error) {
575
+ this.#report(error);
576
+ }
577
+ return stored;
578
+ }
579
+ /** {@inheritDoc SettingsStore.write} */
580
+ async write(owner, overrides, expectedVersion) {
581
+ try {
582
+ return await this.#store.write(owner, overrides, expectedVersion);
583
+ } finally {
584
+ await this.#evict(owner);
585
+ }
586
+ }
587
+ /** {@inheritDoc SettingsStore.delete} */
588
+ async delete(owner) {
589
+ try {
590
+ await this.#store.delete(owner);
591
+ } finally {
592
+ await this.#evict(owner);
593
+ }
594
+ }
595
+ async #evict(owner) {
596
+ try {
597
+ await this.#cache.delete(this.#key(owner));
598
+ } catch (error) {
599
+ this.#report(error);
600
+ }
601
+ }
602
+ #key(owner) {
603
+ return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
604
+ }
605
+ #report(error) {
606
+ this.#onError?.(error);
607
+ }
608
+ };
609
+
610
+ // src/memory-cache.ts
611
+ var MemoryCache = class {
612
+ #entries = /* @__PURE__ */ new Map();
613
+ #maxEntries;
614
+ /**
615
+ * @param options - The size limit.
616
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
617
+ */
618
+ constructor(options = {}) {
619
+ const { maxEntries = 1e3 } = options;
620
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
621
+ throw new RangeError(
622
+ "MemoryCache: maxEntries must be a positive integer."
623
+ );
624
+ }
625
+ this.#maxEntries = maxEntries;
626
+ }
627
+ /** {@inheritDoc SettingsCache.get} */
628
+ async get(key2) {
629
+ const entry = this.#entries.get(key2);
630
+ if (entry === void 0) return void 0;
631
+ if (entry.expiresAt <= Date.now()) {
632
+ this.#entries.delete(key2);
633
+ return void 0;
634
+ }
635
+ return entry.value;
636
+ }
637
+ /** {@inheritDoc SettingsCache.set} */
638
+ async set(key2, value, ttlSeconds) {
639
+ this.#entries.delete(key2);
640
+ this.#entries.set(key2, {
641
+ value,
642
+ expiresAt: Date.now() + ttlSeconds * 1e3
643
+ });
644
+ while (this.#entries.size > this.#maxEntries) {
645
+ const oldest = this.#entries.keys().next().value;
646
+ this.#entries.delete(oldest);
647
+ }
648
+ }
649
+ /** {@inheritDoc SettingsCache.delete} */
650
+ async delete(key2) {
651
+ this.#entries.delete(key2);
652
+ }
653
+ };
654
+
655
+ exports.CachedStore = CachedStore;
656
+ exports.MemoryCache = MemoryCache;
438
657
  exports.MemoryStore = MemoryStore;
658
+ exports.SealedStore = SealedStore;
439
659
  exports.SettingsConflictError = SettingsConflictError;
440
660
  exports.SettingsDefinitionError = SettingsDefinitionError;
441
661
  exports.SettingsValidationError = SettingsValidationError;
package/dist/index.d.cts CHANGED
@@ -180,6 +180,10 @@ interface SettingsOptions<Definitions extends Record<string, SettingsDefinition>
180
180
  * The keys `__proto__`, `constructor` and `prototype` can't be setting names: a write with one in its path or value
181
181
  * throws {@link SettingsValidationError} with a `"Forbidden key"` issue, and `get(path)` reads one as `undefined`.
182
182
  *
183
+ * Overrides are kept as JSON, so a write with a value that isn't JSON, such as a `Date`, a class instance, `NaN` or an
184
+ * array hole, throws {@link SettingsValidationError} with a `"Not a JSON value"` issue at its path, even where the
185
+ * schema accepts it.
186
+ *
183
187
  * @typeParam T - The Scope's settings type.
184
188
  */
185
189
  interface ScopedSettings<T> {
@@ -208,9 +212,11 @@ interface ScopedSettings<T> {
208
212
  * still stored, so a later change of the default doesn't change what the Owner chose.
209
213
  *
210
214
  * @param path - A dot path into the settings, such as `"notifications.email"`.
211
- * @param value - The value to store at `path`.
212
- * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, or the change has
213
- * a key the schema doesn't know, reported as an `"Unknown setting"` issue; nothing is stored.
215
+ * @param value - The value to store at `path`. Use {@link ScopedSettings.reset} rather than `undefined` to remove an
216
+ * Override.
217
+ * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, the change has
218
+ * a key the schema doesn't know, reported as an `"Unknown setting"` issue, or `value` is `undefined` or isn't JSON;
219
+ * nothing is stored.
214
220
  * @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
215
221
  * retries; nothing is stored.
216
222
  */
@@ -221,19 +227,23 @@ interface ScopedSettings<T> {
221
227
  * stored as a value and passes only if the schema allows it.
222
228
  *
223
229
  * @param partial - The settings to change, at any depth, such as `{ theme: "dark", notifications: { email: false } }`.
224
- * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, or `partial` has
225
- * a key the schema doesn't know, reported as an `"Unknown setting"` issue; nothing is stored.
230
+ * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, `partial` has
231
+ * a key the schema doesn't know, reported as an `"Unknown setting"` issue, or a value in it isn't JSON; nothing is
232
+ * stored.
226
233
  * @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
227
234
  * retries; nothing is stored.
228
235
  */
229
236
  update(partial: DeepPartial<T>): Promise<void>;
230
237
  /**
231
- * Reads the Owner's Overrides as stored, without the defaults, for example to show which values are the Owner's
232
- * own choices. Overrides a read would ignore are included until the next write drops them.
238
+ * Reads the Owner's Overrides without the defaults, for example to show which values are the Owner's own choices.
239
+ * Overrides a read would ignore are left out and reported to `onInvalid`, the same as {@link ScopedSettings.get},
240
+ * so the two always agree; the stored document isn't changed. Unlike `get`, it doesn't throw when the Effective
241
+ * settings still fail after leaving those out, so a page can show the Overrides that remain and fix them.
233
242
  *
234
- * @returns The stored Overrides, or `{}` when nothing is stored.
243
+ * @returns The Overrides a read uses, or `{}` when nothing is stored.
244
+ * @throws {@link SettingsDefinitionError} If the Scope's defaults fail its schema.
235
245
  */
236
- getOverrides(): Promise<Record<string, unknown>>;
246
+ getOverrides(): Promise<DeepPartial<T>>;
237
247
  /**
238
248
  * Removes the Override at `path`, so that setting follows the default again. Resetting an object removes every
239
249
  * Override under it. With no Override at or under `path`, it writes nothing.
@@ -257,6 +267,9 @@ type OwnerIdArgs<D> = D extends SettingsDefinition<unknown, true> ? [] : [id: st
257
267
  * Creates the settings for an app's Scopes over one Store. Call `scope(name, id)` on the result to read and write
258
268
  * one Owner's settings; a single-owner Scope takes no id.
259
269
  *
270
+ * The Scope name is stored with every Owner's Overrides, so renaming a Scope needs a migration of its stored rows;
271
+ * until then its Owners read the defaults.
272
+ *
260
273
  * Each Scope's defaults are validated against its schema on the first operation on that Scope, and the outcome is
261
274
  * kept for the life of the returned object. If the validation itself throws, such as an async validator that can't
262
275
  * reach its service, that operation rejects with the error and the next one checks again.
@@ -301,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
301
314
  delete(owner: Owner): Promise<void>;
302
315
  }
303
316
 
317
+ /**
318
+ * The part of a Secret Vault `Vault` that {@link SealedStore} uses, so this package doesn't depend on Secret Vault.
319
+ * A `Vault` fits.
320
+ */
321
+ interface SealingVault {
322
+ /**
323
+ * Seals a JSON value.
324
+ *
325
+ * @param value - The value to seal.
326
+ * @param context - Where the Sealed value is stored.
327
+ * @returns The Sealed value.
328
+ */
329
+ encryptJson(value: unknown, context: string): Promise<string>;
330
+ /**
331
+ * Opens a Sealed value made by `encryptJson`; rejects when it doesn't open.
332
+ *
333
+ * @param sealed - The Sealed value.
334
+ * @param context - The Context it was sealed under.
335
+ * @returns The Plaintext value.
336
+ */
337
+ decryptJson(sealed: string, context: string): Promise<unknown>;
338
+ /**
339
+ * Whether a stored value is a Sealed value rather than plain text still stored at a listed path. Optional: a Secret
340
+ * Vault `Vault` has `isSealed` only as a static method, so without it `SealedStore` treats a string starting with
341
+ * `vault:` as sealed.
342
+ *
343
+ * @param value - A stored value.
344
+ */
345
+ isSealed?(value: unknown): boolean;
346
+ }
347
+ /** Options of {@link SealedStore}. */
348
+ interface SealedStoreOptions {
349
+ /** The Store that keeps the Sealed values. */
350
+ readonly store: SettingsStore;
351
+ /** Seals and opens values; a Secret Vault `Vault` fits. */
352
+ readonly vault: SealingVault;
353
+ /**
354
+ * Dot paths into the settings to keep sealed, such as `"smtp.password"`. A path may name an object, which is sealed
355
+ * whole as JSON. No path may be inside another.
356
+ */
357
+ readonly paths: readonly string[];
358
+ }
359
+ /**
360
+ * A Store that keeps the Overrides at the listed paths as Sealed values in the Store it wraps, and hands the core
361
+ * their Plaintext. The schema validates the Plaintext, and the app calls `set` and `get` with plain values.
362
+ *
363
+ * Every write seals again with the Vault's current secret, which finishes a Rotation for that Owner. A Sealed value
364
+ * that doesn't open makes the read throw: it is never ignored as a stale Override, because the next write would
365
+ * replace the secret with its default. A plain value at a listed path reads as it is and is sealed on the Owner's next
366
+ * write. Values at unlisted paths are never opened.
367
+ *
368
+ * Nest it outermost, around a {@link CachedStore}, so the cache holds only Sealed values.
369
+ *
370
+ * @example
371
+ * const store = new SealedStore({
372
+ * store: new PrismaStore({ delegate: prisma.setting }),
373
+ * vault,
374
+ * paths: ["smtp.password"],
375
+ * });
376
+ */
377
+ declare class SealedStore implements SettingsStore {
378
+ #private;
379
+ /**
380
+ * @param options - The Store to wrap, the Vault and the sealed paths.
381
+ * @throws `TypeError` when a path is empty or inside another path.
382
+ */
383
+ constructor(options: SealedStoreOptions);
384
+ /** {@inheritDoc SettingsStore.read} */
385
+ read(owner: Owner): Promise<StoredOverrides | null>;
386
+ /** {@inheritDoc SettingsStore.write} */
387
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
388
+ /** {@inheritDoc SettingsStore.delete} */
389
+ delete(owner: Owner): Promise<void>;
390
+ }
391
+
392
+ /**
393
+ * A cache of strings with a time to live, which {@link CachedStore} keeps Overrides in. {@link MemoryCache} fits;
394
+ * so does a thin wrapper around Redis or Keyv.
395
+ */
396
+ interface SettingsCache {
397
+ /**
398
+ * Reads an entry.
399
+ *
400
+ * @param key - The cache key.
401
+ * @returns The value, or `undefined` when there is none or it expired.
402
+ */
403
+ get(key: string): Promise<string | undefined>;
404
+ /**
405
+ * Stores an entry, replacing any under the key.
406
+ *
407
+ * @param key - The cache key.
408
+ * @param value - The string to keep.
409
+ * @param ttlSeconds - Seconds until the entry expires.
410
+ */
411
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
412
+ /**
413
+ * Removes an entry; removing a missing one does nothing.
414
+ *
415
+ * @param key - The cache key.
416
+ */
417
+ delete(key: string): Promise<void>;
418
+ }
419
+ /** Options of {@link CachedStore}. */
420
+ interface CachedStoreOptions {
421
+ /** The Store to read through. */
422
+ readonly store: SettingsStore;
423
+ /** Where to keep what the Store returns. */
424
+ readonly cache: SettingsCache;
425
+ /** Seconds an entry stays cached. */
426
+ readonly ttl: number;
427
+ /** Starts every cache key. Defaults to `"settings"`. */
428
+ readonly prefix?: string;
429
+ /** Called with an error a cache method threw; the Store then answers instead. */
430
+ readonly onError?: (error: unknown) => void;
431
+ }
432
+ /**
433
+ * A Store that keeps what the Store it wraps returns in a cache for `ttl` seconds, an Owner with nothing stored
434
+ * included. Every write, conflict and delete evicts the Owner, so a write on this instance is read back at once and a
435
+ * conflicting write retries against fresh Overrides. A cache that throws is reported to `onError` and the Store
436
+ * answers instead.
437
+ *
438
+ * With a per-process cache and several instances, another instance can read Overrides up to `ttl` seconds old. Share
439
+ * a cache, such as Redis, to avoid it.
440
+ *
441
+ * @example
442
+ * const store = new CachedStore({ store: prismaStore, cache: new MemoryCache(), ttl: 60 });
443
+ */
444
+ declare class CachedStore implements SettingsStore {
445
+ #private;
446
+ /**
447
+ * @param options - The Store to wrap, the cache and the TTL.
448
+ * @throws `RangeError` when `ttl` isn't a positive number.
449
+ */
450
+ constructor(options: CachedStoreOptions);
451
+ /** {@inheritDoc SettingsStore.read} */
452
+ read(owner: Owner): Promise<StoredOverrides | null>;
453
+ /** {@inheritDoc SettingsStore.write} */
454
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
455
+ /** {@inheritDoc SettingsStore.delete} */
456
+ delete(owner: Owner): Promise<void>;
457
+ }
458
+
459
+ /** Options of {@link MemoryCache}. */
460
+ interface MemoryCacheOptions {
461
+ /** Most entries kept; the oldest are evicted past it. Defaults to 1000. */
462
+ readonly maxEntries?: number;
463
+ }
464
+ /**
465
+ * A {@link SettingsCache} in process memory. Entries expire after their TTL and the oldest are evicted past
466
+ * `maxEntries`. It is per process: instances don't share it.
467
+ *
468
+ * @example
469
+ * const cache = new MemoryCache({ maxEntries: 5000 });
470
+ */
471
+ declare class MemoryCache implements SettingsCache {
472
+ #private;
473
+ /**
474
+ * @param options - The size limit.
475
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
476
+ */
477
+ constructor(options?: MemoryCacheOptions);
478
+ /** {@inheritDoc SettingsCache.get} */
479
+ get(key: string): Promise<string | undefined>;
480
+ /** {@inheritDoc SettingsCache.set} */
481
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
482
+ /** {@inheritDoc SettingsCache.delete} */
483
+ delete(key: string): Promise<void>;
484
+ }
485
+
304
486
  /**
305
487
  * A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
306
488
  * Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
@@ -345,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
345
527
  constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
346
528
  }
347
529
 
348
- export { MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
530
+ export { CachedStore, type CachedStoreOptions, MemoryCache, type MemoryCacheOptions, MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SealedStore, type SealedStoreOptions, type SealingVault, type SettingsCache, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
package/dist/index.d.ts CHANGED
@@ -180,6 +180,10 @@ interface SettingsOptions<Definitions extends Record<string, SettingsDefinition>
180
180
  * The keys `__proto__`, `constructor` and `prototype` can't be setting names: a write with one in its path or value
181
181
  * throws {@link SettingsValidationError} with a `"Forbidden key"` issue, and `get(path)` reads one as `undefined`.
182
182
  *
183
+ * Overrides are kept as JSON, so a write with a value that isn't JSON, such as a `Date`, a class instance, `NaN` or an
184
+ * array hole, throws {@link SettingsValidationError} with a `"Not a JSON value"` issue at its path, even where the
185
+ * schema accepts it.
186
+ *
183
187
  * @typeParam T - The Scope's settings type.
184
188
  */
185
189
  interface ScopedSettings<T> {
@@ -208,9 +212,11 @@ interface ScopedSettings<T> {
208
212
  * still stored, so a later change of the default doesn't change what the Owner chose.
209
213
  *
210
214
  * @param path - A dot path into the settings, such as `"notifications.email"`.
211
- * @param value - The value to store at `path`.
212
- * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, or the change has
213
- * a key the schema doesn't know, reported as an `"Unknown setting"` issue; nothing is stored.
215
+ * @param value - The value to store at `path`. Use {@link ScopedSettings.reset} rather than `undefined` to remove an
216
+ * Override.
217
+ * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, the change has
218
+ * a key the schema doesn't know, reported as an `"Unknown setting"` issue, or `value` is `undefined` or isn't JSON;
219
+ * nothing is stored.
214
220
  * @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
215
221
  * retries; nothing is stored.
216
222
  */
@@ -221,19 +227,23 @@ interface ScopedSettings<T> {
221
227
  * stored as a value and passes only if the schema allows it.
222
228
  *
223
229
  * @param partial - The settings to change, at any depth, such as `{ theme: "dark", notifications: { email: false } }`.
224
- * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, or `partial` has
225
- * a key the schema doesn't know, reported as an `"Unknown setting"` issue; nothing is stored.
230
+ * @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, `partial` has
231
+ * a key the schema doesn't know, reported as an `"Unknown setting"` issue, or a value in it isn't JSON; nothing is
232
+ * stored.
226
233
  * @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
227
234
  * retries; nothing is stored.
228
235
  */
229
236
  update(partial: DeepPartial<T>): Promise<void>;
230
237
  /**
231
- * Reads the Owner's Overrides as stored, without the defaults, for example to show which values are the Owner's
232
- * own choices. Overrides a read would ignore are included until the next write drops them.
238
+ * Reads the Owner's Overrides without the defaults, for example to show which values are the Owner's own choices.
239
+ * Overrides a read would ignore are left out and reported to `onInvalid`, the same as {@link ScopedSettings.get},
240
+ * so the two always agree; the stored document isn't changed. Unlike `get`, it doesn't throw when the Effective
241
+ * settings still fail after leaving those out, so a page can show the Overrides that remain and fix them.
233
242
  *
234
- * @returns The stored Overrides, or `{}` when nothing is stored.
243
+ * @returns The Overrides a read uses, or `{}` when nothing is stored.
244
+ * @throws {@link SettingsDefinitionError} If the Scope's defaults fail its schema.
235
245
  */
236
- getOverrides(): Promise<Record<string, unknown>>;
246
+ getOverrides(): Promise<DeepPartial<T>>;
237
247
  /**
238
248
  * Removes the Override at `path`, so that setting follows the default again. Resetting an object removes every
239
249
  * Override under it. With no Override at or under `path`, it writes nothing.
@@ -257,6 +267,9 @@ type OwnerIdArgs<D> = D extends SettingsDefinition<unknown, true> ? [] : [id: st
257
267
  * Creates the settings for an app's Scopes over one Store. Call `scope(name, id)` on the result to read and write
258
268
  * one Owner's settings; a single-owner Scope takes no id.
259
269
  *
270
+ * The Scope name is stored with every Owner's Overrides, so renaming a Scope needs a migration of its stored rows;
271
+ * until then its Owners read the defaults.
272
+ *
260
273
  * Each Scope's defaults are validated against its schema on the first operation on that Scope, and the outcome is
261
274
  * kept for the life of the returned object. If the validation itself throws, such as an async validator that can't
262
275
  * reach its service, that operation rejects with the error and the next one checks again.
@@ -301,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
301
314
  delete(owner: Owner): Promise<void>;
302
315
  }
303
316
 
317
+ /**
318
+ * The part of a Secret Vault `Vault` that {@link SealedStore} uses, so this package doesn't depend on Secret Vault.
319
+ * A `Vault` fits.
320
+ */
321
+ interface SealingVault {
322
+ /**
323
+ * Seals a JSON value.
324
+ *
325
+ * @param value - The value to seal.
326
+ * @param context - Where the Sealed value is stored.
327
+ * @returns The Sealed value.
328
+ */
329
+ encryptJson(value: unknown, context: string): Promise<string>;
330
+ /**
331
+ * Opens a Sealed value made by `encryptJson`; rejects when it doesn't open.
332
+ *
333
+ * @param sealed - The Sealed value.
334
+ * @param context - The Context it was sealed under.
335
+ * @returns The Plaintext value.
336
+ */
337
+ decryptJson(sealed: string, context: string): Promise<unknown>;
338
+ /**
339
+ * Whether a stored value is a Sealed value rather than plain text still stored at a listed path. Optional: a Secret
340
+ * Vault `Vault` has `isSealed` only as a static method, so without it `SealedStore` treats a string starting with
341
+ * `vault:` as sealed.
342
+ *
343
+ * @param value - A stored value.
344
+ */
345
+ isSealed?(value: unknown): boolean;
346
+ }
347
+ /** Options of {@link SealedStore}. */
348
+ interface SealedStoreOptions {
349
+ /** The Store that keeps the Sealed values. */
350
+ readonly store: SettingsStore;
351
+ /** Seals and opens values; a Secret Vault `Vault` fits. */
352
+ readonly vault: SealingVault;
353
+ /**
354
+ * Dot paths into the settings to keep sealed, such as `"smtp.password"`. A path may name an object, which is sealed
355
+ * whole as JSON. No path may be inside another.
356
+ */
357
+ readonly paths: readonly string[];
358
+ }
359
+ /**
360
+ * A Store that keeps the Overrides at the listed paths as Sealed values in the Store it wraps, and hands the core
361
+ * their Plaintext. The schema validates the Plaintext, and the app calls `set` and `get` with plain values.
362
+ *
363
+ * Every write seals again with the Vault's current secret, which finishes a Rotation for that Owner. A Sealed value
364
+ * that doesn't open makes the read throw: it is never ignored as a stale Override, because the next write would
365
+ * replace the secret with its default. A plain value at a listed path reads as it is and is sealed on the Owner's next
366
+ * write. Values at unlisted paths are never opened.
367
+ *
368
+ * Nest it outermost, around a {@link CachedStore}, so the cache holds only Sealed values.
369
+ *
370
+ * @example
371
+ * const store = new SealedStore({
372
+ * store: new PrismaStore({ delegate: prisma.setting }),
373
+ * vault,
374
+ * paths: ["smtp.password"],
375
+ * });
376
+ */
377
+ declare class SealedStore implements SettingsStore {
378
+ #private;
379
+ /**
380
+ * @param options - The Store to wrap, the Vault and the sealed paths.
381
+ * @throws `TypeError` when a path is empty or inside another path.
382
+ */
383
+ constructor(options: SealedStoreOptions);
384
+ /** {@inheritDoc SettingsStore.read} */
385
+ read(owner: Owner): Promise<StoredOverrides | null>;
386
+ /** {@inheritDoc SettingsStore.write} */
387
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
388
+ /** {@inheritDoc SettingsStore.delete} */
389
+ delete(owner: Owner): Promise<void>;
390
+ }
391
+
392
+ /**
393
+ * A cache of strings with a time to live, which {@link CachedStore} keeps Overrides in. {@link MemoryCache} fits;
394
+ * so does a thin wrapper around Redis or Keyv.
395
+ */
396
+ interface SettingsCache {
397
+ /**
398
+ * Reads an entry.
399
+ *
400
+ * @param key - The cache key.
401
+ * @returns The value, or `undefined` when there is none or it expired.
402
+ */
403
+ get(key: string): Promise<string | undefined>;
404
+ /**
405
+ * Stores an entry, replacing any under the key.
406
+ *
407
+ * @param key - The cache key.
408
+ * @param value - The string to keep.
409
+ * @param ttlSeconds - Seconds until the entry expires.
410
+ */
411
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
412
+ /**
413
+ * Removes an entry; removing a missing one does nothing.
414
+ *
415
+ * @param key - The cache key.
416
+ */
417
+ delete(key: string): Promise<void>;
418
+ }
419
+ /** Options of {@link CachedStore}. */
420
+ interface CachedStoreOptions {
421
+ /** The Store to read through. */
422
+ readonly store: SettingsStore;
423
+ /** Where to keep what the Store returns. */
424
+ readonly cache: SettingsCache;
425
+ /** Seconds an entry stays cached. */
426
+ readonly ttl: number;
427
+ /** Starts every cache key. Defaults to `"settings"`. */
428
+ readonly prefix?: string;
429
+ /** Called with an error a cache method threw; the Store then answers instead. */
430
+ readonly onError?: (error: unknown) => void;
431
+ }
432
+ /**
433
+ * A Store that keeps what the Store it wraps returns in a cache for `ttl` seconds, an Owner with nothing stored
434
+ * included. Every write, conflict and delete evicts the Owner, so a write on this instance is read back at once and a
435
+ * conflicting write retries against fresh Overrides. A cache that throws is reported to `onError` and the Store
436
+ * answers instead.
437
+ *
438
+ * With a per-process cache and several instances, another instance can read Overrides up to `ttl` seconds old. Share
439
+ * a cache, such as Redis, to avoid it.
440
+ *
441
+ * @example
442
+ * const store = new CachedStore({ store: prismaStore, cache: new MemoryCache(), ttl: 60 });
443
+ */
444
+ declare class CachedStore implements SettingsStore {
445
+ #private;
446
+ /**
447
+ * @param options - The Store to wrap, the cache and the TTL.
448
+ * @throws `RangeError` when `ttl` isn't a positive number.
449
+ */
450
+ constructor(options: CachedStoreOptions);
451
+ /** {@inheritDoc SettingsStore.read} */
452
+ read(owner: Owner): Promise<StoredOverrides | null>;
453
+ /** {@inheritDoc SettingsStore.write} */
454
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
455
+ /** {@inheritDoc SettingsStore.delete} */
456
+ delete(owner: Owner): Promise<void>;
457
+ }
458
+
459
+ /** Options of {@link MemoryCache}. */
460
+ interface MemoryCacheOptions {
461
+ /** Most entries kept; the oldest are evicted past it. Defaults to 1000. */
462
+ readonly maxEntries?: number;
463
+ }
464
+ /**
465
+ * A {@link SettingsCache} in process memory. Entries expire after their TTL and the oldest are evicted past
466
+ * `maxEntries`. It is per process: instances don't share it.
467
+ *
468
+ * @example
469
+ * const cache = new MemoryCache({ maxEntries: 5000 });
470
+ */
471
+ declare class MemoryCache implements SettingsCache {
472
+ #private;
473
+ /**
474
+ * @param options - The size limit.
475
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
476
+ */
477
+ constructor(options?: MemoryCacheOptions);
478
+ /** {@inheritDoc SettingsCache.get} */
479
+ get(key: string): Promise<string | undefined>;
480
+ /** {@inheritDoc SettingsCache.set} */
481
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
482
+ /** {@inheritDoc SettingsCache.delete} */
483
+ delete(key: string): Promise<void>;
484
+ }
485
+
304
486
  /**
305
487
  * A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
306
488
  * Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
@@ -345,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
345
527
  constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
346
528
  }
347
529
 
348
- export { MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
530
+ export { CachedStore, type CachedStoreOptions, MemoryCache, type MemoryCacheOptions, MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SealedStore, type SealedStoreOptions, type SealingVault, type SettingsCache, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
package/dist/index.js CHANGED
@@ -138,6 +138,25 @@ function forbiddenKeysIn(value, path) {
138
138
  ([key2, child]) => FORBIDDEN_KEYS.has(key2) ? [{ message: "Forbidden key", path: [...path, key2] }] : forbiddenKeysIn(child, [...path, key2])
139
139
  );
140
140
  }
141
+ function nonJsonValues(path, value) {
142
+ return nonJsonValuesIn(value, path === void 0 ? [] : keysOf(path));
143
+ }
144
+ function nonJsonValuesIn(value, path) {
145
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
146
+ return [];
147
+ }
148
+ if (Array.isArray(value)) {
149
+ return Array.from(value).flatMap(
150
+ (item, index) => nonJsonValuesIn(item, [...path, index])
151
+ );
152
+ }
153
+ if (isPlainObject(value) && Object.getOwnPropertySymbols(value).length === 0) {
154
+ return Object.entries(value).flatMap(
155
+ ([key2, child]) => child === void 0 ? [] : nonJsonValuesIn(child, [...path, key2])
156
+ );
157
+ }
158
+ return [{ message: "Not a JSON value", path: [...path] }];
159
+ }
141
160
  function getAt(value, path) {
142
161
  let current = value;
143
162
  for (const key2 of keysOf(path)) {
@@ -347,16 +366,27 @@ function createSettings(options) {
347
366
  },
348
367
  async set(path, value) {
349
368
  rejectForbiddenKeys(path, value);
369
+ if (value === void 0) {
370
+ throw new SettingsValidationError([
371
+ {
372
+ message: "Not a JSON value: use reset(path) to remove an Override",
373
+ path: path.split(".")
374
+ }
375
+ ]);
376
+ }
377
+ rejectNonJsonValues(path, value);
350
378
  await change((overrides) => setAt(overrides, path, value));
351
379
  },
352
380
  async update(partial) {
353
381
  rejectForbiddenKeys(void 0, partial);
382
+ rejectNonJsonValues(void 0, partial);
354
383
  await change(
355
384
  (overrides) => mergeOverrides(overrides, withoutUndefined(partial))
356
385
  );
357
386
  },
358
387
  async getOverrides() {
359
- return (await readOverrides())?.overrides ?? {};
388
+ const stored = await readOverrides();
389
+ return (await withoutStale(stored?.overrides ?? {})).overrides;
360
390
  },
361
391
  async reset(path) {
362
392
  if (path === void 0) {
@@ -391,6 +421,10 @@ function rejectForbiddenKeys(path, value) {
391
421
  const issues = forbiddenKeys(path, value);
392
422
  if (issues.length > 0) throw new SettingsValidationError(issues);
393
423
  }
424
+ function rejectNonJsonValues(path, value) {
425
+ const issues = nonJsonValues(path, value);
426
+ if (issues.length > 0) throw new SettingsValidationError(issues);
427
+ }
394
428
  function unknownSettings(overrides, output) {
395
429
  return unknownKeys(overrides, output).map((path) => ({
396
430
  message: "Unknown setting",
@@ -433,4 +467,187 @@ function asJson(overrides) {
433
467
  return JSON.parse(JSON.stringify(overrides));
434
468
  }
435
469
 
436
- export { MemoryStore, SettingsConflictError, SettingsDefinitionError, SettingsValidationError, createSettings, defineSettings };
470
+ // src/sealed-store.ts
471
+ function defaultIsSealed(value) {
472
+ return typeof value === "string" && value.startsWith("vault:");
473
+ }
474
+ function contextOf(owner, path) {
475
+ return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
476
+ }
477
+ var SealedStore = class {
478
+ #store;
479
+ #vault;
480
+ #paths;
481
+ /**
482
+ * @param options - The Store to wrap, the Vault and the sealed paths.
483
+ * @throws `TypeError` when a path is empty or inside another path.
484
+ */
485
+ constructor(options) {
486
+ this.#store = options.store;
487
+ this.#vault = options.vault;
488
+ this.#paths = [...new Set(options.paths)];
489
+ for (const path of this.#paths) {
490
+ if (path === "") throw new TypeError("SealedStore: a path is empty.");
491
+ const inner = this.#paths.find((p) => p.startsWith(`${path}.`));
492
+ if (inner !== void 0) {
493
+ throw new TypeError(
494
+ `SealedStore: path "${inner}" is inside path "${path}".`
495
+ );
496
+ }
497
+ }
498
+ }
499
+ /** {@inheritDoc SettingsStore.read} */
500
+ async read(owner) {
501
+ const stored = await this.#store.read(owner);
502
+ if (stored === null) return null;
503
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
504
+ let overrides = stored.overrides;
505
+ for (const path of this.#paths) {
506
+ const value = getAt(overrides, path);
507
+ if (!isSealed(value)) continue;
508
+ const plaintext = await this.#vault.decryptJson(
509
+ value,
510
+ contextOf(owner, path)
511
+ );
512
+ overrides = setAt(overrides, path, plaintext);
513
+ }
514
+ return { overrides, version: stored.version };
515
+ }
516
+ /** {@inheritDoc SettingsStore.write} */
517
+ async write(owner, overrides, expectedVersion) {
518
+ let sealed = overrides;
519
+ for (const path of this.#paths) {
520
+ if (!hasAt(sealed, path)) continue;
521
+ const value = getAt(sealed, path);
522
+ if (value === void 0) continue;
523
+ sealed = setAt(
524
+ sealed,
525
+ path,
526
+ await this.#vault.encryptJson(value, contextOf(owner, path))
527
+ );
528
+ }
529
+ return this.#store.write(owner, sealed, expectedVersion);
530
+ }
531
+ /** {@inheritDoc SettingsStore.delete} */
532
+ async delete(owner) {
533
+ await this.#store.delete(owner);
534
+ }
535
+ };
536
+
537
+ // src/cached-store.ts
538
+ var CachedStore = class {
539
+ #store;
540
+ #cache;
541
+ #ttl;
542
+ #prefix;
543
+ #onError;
544
+ /**
545
+ * @param options - The Store to wrap, the cache and the TTL.
546
+ * @throws `RangeError` when `ttl` isn't a positive number.
547
+ */
548
+ constructor(options) {
549
+ if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
550
+ throw new RangeError("CachedStore: ttl must be a positive number.");
551
+ }
552
+ this.#store = options.store;
553
+ this.#cache = options.cache;
554
+ this.#ttl = options.ttl;
555
+ this.#prefix = options.prefix ?? "settings";
556
+ this.#onError = options.onError;
557
+ }
558
+ /** {@inheritDoc SettingsStore.read} */
559
+ async read(owner) {
560
+ const key2 = this.#key(owner);
561
+ try {
562
+ const cached = await this.#cache.get(key2);
563
+ if (cached !== void 0) {
564
+ return JSON.parse(cached);
565
+ }
566
+ } catch (error) {
567
+ this.#report(error);
568
+ }
569
+ const stored = await this.#store.read(owner);
570
+ try {
571
+ await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
572
+ } catch (error) {
573
+ this.#report(error);
574
+ }
575
+ return stored;
576
+ }
577
+ /** {@inheritDoc SettingsStore.write} */
578
+ async write(owner, overrides, expectedVersion) {
579
+ try {
580
+ return await this.#store.write(owner, overrides, expectedVersion);
581
+ } finally {
582
+ await this.#evict(owner);
583
+ }
584
+ }
585
+ /** {@inheritDoc SettingsStore.delete} */
586
+ async delete(owner) {
587
+ try {
588
+ await this.#store.delete(owner);
589
+ } finally {
590
+ await this.#evict(owner);
591
+ }
592
+ }
593
+ async #evict(owner) {
594
+ try {
595
+ await this.#cache.delete(this.#key(owner));
596
+ } catch (error) {
597
+ this.#report(error);
598
+ }
599
+ }
600
+ #key(owner) {
601
+ return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
602
+ }
603
+ #report(error) {
604
+ this.#onError?.(error);
605
+ }
606
+ };
607
+
608
+ // src/memory-cache.ts
609
+ var MemoryCache = class {
610
+ #entries = /* @__PURE__ */ new Map();
611
+ #maxEntries;
612
+ /**
613
+ * @param options - The size limit.
614
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
615
+ */
616
+ constructor(options = {}) {
617
+ const { maxEntries = 1e3 } = options;
618
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
619
+ throw new RangeError(
620
+ "MemoryCache: maxEntries must be a positive integer."
621
+ );
622
+ }
623
+ this.#maxEntries = maxEntries;
624
+ }
625
+ /** {@inheritDoc SettingsCache.get} */
626
+ async get(key2) {
627
+ const entry = this.#entries.get(key2);
628
+ if (entry === void 0) return void 0;
629
+ if (entry.expiresAt <= Date.now()) {
630
+ this.#entries.delete(key2);
631
+ return void 0;
632
+ }
633
+ return entry.value;
634
+ }
635
+ /** {@inheritDoc SettingsCache.set} */
636
+ async set(key2, value, ttlSeconds) {
637
+ this.#entries.delete(key2);
638
+ this.#entries.set(key2, {
639
+ value,
640
+ expiresAt: Date.now() + ttlSeconds * 1e3
641
+ });
642
+ while (this.#entries.size > this.#maxEntries) {
643
+ const oldest = this.#entries.keys().next().value;
644
+ this.#entries.delete(oldest);
645
+ }
646
+ }
647
+ /** {@inheritDoc SettingsCache.delete} */
648
+ async delete(key2) {
649
+ this.#entries.delete(key2);
650
+ }
651
+ };
652
+
653
+ export { CachedStore, MemoryCache, MemoryStore, SealedStore, SettingsConflictError, SettingsDefinitionError, SettingsValidationError, createSettings, defineSettings };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezwanul7/settings",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@repo/eslint-config": "0.0.0",
48
+ "@rezwanul7/secret-vault": "0.1.0",
48
49
  "@repo/typescript-config": "0.0.0",
49
50
  "@types/node": "^22.15.3",
50
51
  "@vitest/coverage-v8": "4.0.18",