@rezwanul7/settings 1.0.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/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) {
@@ -469,7 +501,306 @@ function asJson(overrides) {
469
501
  return JSON.parse(JSON.stringify(overrides));
470
502
  }
471
503
 
504
+ // src/sealed-store.ts
505
+ var DISCARD_RETRIES = 3;
506
+ function defaultIsSealed(value) {
507
+ return typeof value === "string" && value.startsWith("vault:");
508
+ }
509
+ function contextOf(owner, path) {
510
+ return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
511
+ }
512
+ var SealedStore = class {
513
+ #store;
514
+ #vault;
515
+ #paths;
516
+ /**
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.
520
+ */
521
+ constructor(options) {
522
+ this.#store = options.store;
523
+ this.#vault = options.vault;
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)) {
529
+ throw new TypeError(
530
+ `SealedStore: Scope "${scope}" in paths isn't in definitions.`
531
+ );
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.");
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) ?? [];
557
+ }
558
+ /** {@inheritDoc SettingsStore.read} */
559
+ async read(owner) {
560
+ const stored = await this.#store.read(owner);
561
+ if (stored === null) return null;
562
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
563
+ let overrides = stored.overrides;
564
+ for (const path of this.#pathsOf(owner)) {
565
+ const value = getAt(overrides, path);
566
+ if (!isSealed(value)) continue;
567
+ const plaintext = await this.#vault.decryptJson(
568
+ value,
569
+ contextOf(owner, path)
570
+ );
571
+ overrides = setAt(overrides, path, plaintext);
572
+ }
573
+ return { overrides, version: stored.version };
574
+ }
575
+ /** {@inheritDoc SettingsStore.write} */
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) {
588
+ let sealed = overrides;
589
+ for (const path of this.#pathsOf(owner)) {
590
+ if (!hasAt(sealed, path)) continue;
591
+ const value = getAt(sealed, path);
592
+ if (value === void 0 || skip(value)) continue;
593
+ sealed = setAt(
594
+ sealed,
595
+ path,
596
+ await this.#vault.encryptJson(value, contextOf(owner, path))
597
+ );
598
+ }
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);
645
+ }
646
+ /** {@inheritDoc SettingsStore.delete} */
647
+ async delete(owner) {
648
+ await this.#store.delete(owner);
649
+ }
650
+ };
651
+
652
+ // src/cached-store.ts
653
+ var CachedStore = class {
654
+ #store;
655
+ #cache;
656
+ #ttl;
657
+ #prefix;
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();
665
+ /**
666
+ * @param options - The Store to wrap, the cache and the TTL.
667
+ * @throws `RangeError` when `ttl` isn't a positive number.
668
+ */
669
+ constructor(options) {
670
+ if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
671
+ throw new RangeError("CachedStore: ttl must be a positive number.");
672
+ }
673
+ this.#store = options.store;
674
+ this.#cache = options.cache;
675
+ this.#ttl = options.ttl;
676
+ this.#prefix = options.prefix ?? "settings";
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
+ );
681
+ }
682
+ /** {@inheritDoc SettingsStore.read} */
683
+ async read(owner) {
684
+ const key2 = this.#key(owner);
685
+ try {
686
+ const cached = await this.#cache.get(key2);
687
+ if (cached !== void 0) {
688
+ return JSON.parse(cached);
689
+ }
690
+ } catch (error) {
691
+ this.#report(error);
692
+ }
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;
700
+ try {
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);
719
+ }
720
+ }
721
+ /** {@inheritDoc SettingsStore.write} */
722
+ async write(owner, overrides, expectedVersion) {
723
+ try {
724
+ return await this.#store.write(owner, overrides, expectedVersion);
725
+ } finally {
726
+ await this.#evict(owner);
727
+ }
728
+ }
729
+ /** {@inheritDoc SettingsStore.delete} */
730
+ async delete(owner) {
731
+ try {
732
+ await this.#store.delete(owner);
733
+ } finally {
734
+ await this.#evict(owner);
735
+ }
736
+ }
737
+ async #evict(owner) {
738
+ const key2 = this.#key(owner);
739
+ const reading = this.#inFlight.get(key2);
740
+ if (reading !== void 0) reading.evictions++;
741
+ try {
742
+ await this.#cache.delete(key2);
743
+ } catch (error) {
744
+ this.#report(error);
745
+ }
746
+ }
747
+ #key(owner) {
748
+ return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
749
+ }
750
+ #report(error) {
751
+ this.#onError?.(error);
752
+ }
753
+ };
754
+
755
+ // src/memory-cache.ts
756
+ var MemoryCache = class {
757
+ #entries = /* @__PURE__ */ new Map();
758
+ #maxEntries;
759
+ /**
760
+ * @param options - The size limit.
761
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
762
+ */
763
+ constructor(options = {}) {
764
+ const { maxEntries = 1e3 } = options;
765
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
766
+ throw new RangeError(
767
+ "MemoryCache: maxEntries must be a positive integer."
768
+ );
769
+ }
770
+ this.#maxEntries = maxEntries;
771
+ }
772
+ /** {@inheritDoc SettingsCache.get} */
773
+ async get(key2) {
774
+ const entry = this.#entries.get(key2);
775
+ if (entry === void 0) return void 0;
776
+ if (entry.expiresAt <= Date.now()) {
777
+ this.#entries.delete(key2);
778
+ return void 0;
779
+ }
780
+ return entry.value;
781
+ }
782
+ /** {@inheritDoc SettingsCache.set} */
783
+ async set(key2, value, ttlSeconds) {
784
+ this.#entries.delete(key2);
785
+ this.#entries.set(key2, {
786
+ value,
787
+ expiresAt: Date.now() + ttlSeconds * 1e3
788
+ });
789
+ while (this.#entries.size > this.#maxEntries) {
790
+ const oldest = this.#entries.keys().next().value;
791
+ this.#entries.delete(oldest);
792
+ }
793
+ }
794
+ /** {@inheritDoc SettingsCache.delete} */
795
+ async delete(key2) {
796
+ this.#entries.delete(key2);
797
+ }
798
+ };
799
+
800
+ exports.CachedStore = CachedStore;
801
+ exports.MemoryCache = MemoryCache;
472
802
  exports.MemoryStore = MemoryStore;
803
+ exports.SealedStore = SealedStore;
473
804
  exports.SettingsConflictError = SettingsConflictError;
474
805
  exports.SettingsDefinitionError = SettingsDefinitionError;
475
806
  exports.SettingsValidationError = SettingsValidationError;