@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.js CHANGED
@@ -247,10 +247,39 @@ function coveringOverride(overrides, keys) {
247
247
  return [...keys];
248
248
  }
249
249
 
250
+ // src/warn-once.ts
251
+ function warnOnce(message) {
252
+ let warned = false;
253
+ return (failure) => {
254
+ if (warned) return;
255
+ warned = true;
256
+ console.warn(message, failure);
257
+ };
258
+ }
259
+ function guardHook(hook, message) {
260
+ if (hook === void 0) return void 0;
261
+ const warn = warnOnce(message);
262
+ return (...args) => {
263
+ try {
264
+ const result = hook(...args);
265
+ if (isThenable(result)) Promise.resolve(result).catch(warn);
266
+ } catch (error) {
267
+ warn(error);
268
+ }
269
+ };
270
+ }
271
+ function isThenable(value) {
272
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
273
+ }
274
+
250
275
  // src/settings.ts
251
276
  var SINGLE_OWNER_ID = "default";
252
277
  function createSettings(options) {
253
- const { definitions, store, onInvalid, maxRetries = 3 } = options;
278
+ const { definitions, store, maxRetries = 3 } = options;
279
+ const onInvalid = guardHook(
280
+ options.onInvalid,
281
+ "@rezwanul7/settings: onInvalid failed, and the operation went on without it. Later failures of this onInvalid aren't reported."
282
+ );
254
283
  if (!Number.isInteger(maxRetries) || maxRetries < 0) {
255
284
  throw new RangeError(
256
285
  `maxRetries must be a whole number, 0 or more; got ${maxRetries}.`
@@ -415,6 +444,9 @@ function ownerOf(scope, definition, id) {
415
444
  if (typeof id !== "string") {
416
445
  throw new TypeError(`Scope "${scope}" needs an Owner id.`);
417
446
  }
447
+ if (id === "") {
448
+ throw new TypeError(`Scope "${scope}" needs a non-empty Owner id.`);
449
+ }
418
450
  return { scope, id };
419
451
  }
420
452
  function rejectForbiddenKeys(path, value) {
@@ -467,4 +499,300 @@ function asJson(overrides) {
467
499
  return JSON.parse(JSON.stringify(overrides));
468
500
  }
469
501
 
470
- export { MemoryStore, SettingsConflictError, SettingsDefinitionError, SettingsValidationError, createSettings, defineSettings };
502
+ // src/sealed-store.ts
503
+ var DISCARD_RETRIES = 3;
504
+ function defaultIsSealed(value) {
505
+ return typeof value === "string" && value.startsWith("vault:");
506
+ }
507
+ function contextOf(owner, path) {
508
+ return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
509
+ }
510
+ var SealedStore = class {
511
+ #store;
512
+ #vault;
513
+ #paths;
514
+ /**
515
+ * @param options - The Store to wrap, the Vault, the Settings definitions and the sealed paths by Scope.
516
+ * @throws `TypeError` when no Scope lists a path, a Scope in `paths` isn't in `definitions`, a path is empty, or a
517
+ * path is inside another of the same Scope.
518
+ */
519
+ constructor(options) {
520
+ this.#store = options.store;
521
+ this.#vault = options.vault;
522
+ const paths = /* @__PURE__ */ new Map();
523
+ for (const [scope, list] of Object.entries(
524
+ options.paths
525
+ )) {
526
+ if (!Object.hasOwn(options.definitions, scope)) {
527
+ throw new TypeError(
528
+ `SealedStore: Scope "${scope}" in paths isn't in definitions.`
529
+ );
530
+ }
531
+ const unique = [...new Set(list ?? [])];
532
+ for (const path of unique) {
533
+ if (path === "") {
534
+ throw new TypeError(
535
+ `SealedStore: a path of Scope "${scope}" is empty.`
536
+ );
537
+ }
538
+ const inner = unique.find((p) => p.startsWith(`${path}.`));
539
+ if (inner !== void 0) {
540
+ throw new TypeError(
541
+ `SealedStore: path "${inner}" is inside path "${path}" in Scope "${scope}".`
542
+ );
543
+ }
544
+ }
545
+ if (unique.length > 0) paths.set(scope, unique);
546
+ }
547
+ if (paths.size === 0) {
548
+ throw new TypeError("SealedStore: no Scope lists a path to seal.");
549
+ }
550
+ this.#paths = paths;
551
+ }
552
+ /** The paths sealed for the Owner's Scope. */
553
+ #pathsOf(owner) {
554
+ return this.#paths.get(owner.scope) ?? [];
555
+ }
556
+ /** {@inheritDoc SettingsStore.read} */
557
+ async read(owner) {
558
+ const stored = await this.#store.read(owner);
559
+ if (stored === null) return null;
560
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
561
+ let overrides = stored.overrides;
562
+ for (const path of this.#pathsOf(owner)) {
563
+ const value = getAt(overrides, path);
564
+ if (!isSealed(value)) continue;
565
+ const plaintext = await this.#vault.decryptJson(
566
+ value,
567
+ contextOf(owner, path)
568
+ );
569
+ overrides = setAt(overrides, path, plaintext);
570
+ }
571
+ return { overrides, version: stored.version };
572
+ }
573
+ /** {@inheritDoc SettingsStore.write} */
574
+ async write(owner, overrides, expectedVersion) {
575
+ return this.#store.write(
576
+ owner,
577
+ await this.#seal(owner, overrides, () => false),
578
+ expectedVersion
579
+ );
580
+ }
581
+ /**
582
+ * Seals the values at the paths listed for the Owner's Scope, skipping those `skip` matches; `discard` skips the
583
+ * values already sealed, so it never opens or seals them again.
584
+ */
585
+ async #seal(owner, overrides, skip) {
586
+ let sealed = overrides;
587
+ for (const path of this.#pathsOf(owner)) {
588
+ if (!hasAt(sealed, path)) continue;
589
+ const value = getAt(sealed, path);
590
+ if (value === void 0 || skip(value)) continue;
591
+ sealed = setAt(
592
+ sealed,
593
+ path,
594
+ await this.#vault.encryptJson(value, contextOf(owner, path))
595
+ );
596
+ }
597
+ return sealed;
598
+ }
599
+ /**
600
+ * Removes one Sealed value that won't open, so the Owner can read its settings and set a new secret again; its
601
+ * other Sealed values stay sealed and aren't opened, and a plain value at another listed path is sealed, as on any
602
+ * write. Its other Overrides stay as they are. A value that opens, a
603
+ * plain value and an empty path are left alone, so running it again after the Owner set a new secret changes
604
+ * nothing. Any rejection from `decryptJson` counts as not opening.
605
+ *
606
+ * **Check the Vault secret first.** With a wrong or missing secret every Sealed value fails to open, so `discard`
607
+ * would remove secrets that fixing the configuration would have recovered.
608
+ *
609
+ * It reads the wrapped Store and writes with the version it read. On a conflict it reads again and retries, up to 3
610
+ * times; a {@link CachedStore} underneath evicts on the conflict, so the retry reads fresh Overrides.
611
+ *
612
+ * @param owner - The Owner whose Sealed value to remove.
613
+ * @param path - A path listed for the Owner's Scope, such as `"smtp.password"`.
614
+ * @returns `true` when a Sealed value that won't open was removed, `false` when nothing was changed.
615
+ * @throws `TypeError` when `path` isn't listed for the Owner's Scope.
616
+ * @throws {@link SettingsConflictError} when every write lost the version race to another write.
617
+ */
618
+ async discard(owner, path) {
619
+ if (!this.#pathsOf(owner).includes(path)) {
620
+ throw new TypeError(
621
+ `SealedStore: path "${path}" isn't sealed in Scope "${owner.scope}".`
622
+ );
623
+ }
624
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
625
+ for (let attempt = 0; attempt <= DISCARD_RETRIES; attempt++) {
626
+ const stored = await this.#store.read(owner);
627
+ if (stored === null || !hasAt(stored.overrides, path)) return false;
628
+ const value = getAt(stored.overrides, path);
629
+ if (!isSealed(value)) return false;
630
+ const opens = await this.#vault.decryptJson(value, contextOf(owner, path)).then(
631
+ () => true,
632
+ () => false
633
+ );
634
+ if (opens) return false;
635
+ const version = await this.#store.write(
636
+ owner,
637
+ await this.#seal(owner, removeAt(stored.overrides, path), isSealed),
638
+ stored.version
639
+ );
640
+ if (version !== null) return true;
641
+ }
642
+ throw new SettingsConflictError(owner, DISCARD_RETRIES);
643
+ }
644
+ /** {@inheritDoc SettingsStore.delete} */
645
+ async delete(owner) {
646
+ await this.#store.delete(owner);
647
+ }
648
+ };
649
+
650
+ // src/cached-store.ts
651
+ var CachedStore = class {
652
+ #store;
653
+ #cache;
654
+ #ttl;
655
+ #prefix;
656
+ #onError;
657
+ /**
658
+ * The Owners with a read of the wrapped Store in flight, by cache key: how many such reads there are, and how many
659
+ * evictions happened since the first began. A read caches only if no eviction happened during it. An entry goes
660
+ * when its last read finishes, so this holds only the Owners being read right now.
661
+ */
662
+ #inFlight = /* @__PURE__ */ new Map();
663
+ /**
664
+ * @param options - The Store to wrap, the cache and the TTL.
665
+ * @throws `RangeError` when `ttl` isn't a positive number.
666
+ */
667
+ constructor(options) {
668
+ if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
669
+ throw new RangeError("CachedStore: ttl must be a positive number.");
670
+ }
671
+ this.#store = options.store;
672
+ this.#cache = options.cache;
673
+ this.#ttl = options.ttl;
674
+ this.#prefix = options.prefix ?? "settings";
675
+ this.#onError = guardHook(
676
+ options.onError,
677
+ "@rezwanul7/settings: CachedStore onError failed, and the operation went on without it. Later failures of this onError aren't reported."
678
+ );
679
+ }
680
+ /** {@inheritDoc SettingsStore.read} */
681
+ async read(owner) {
682
+ const key2 = this.#key(owner);
683
+ try {
684
+ const cached = await this.#cache.get(key2);
685
+ if (cached !== void 0) {
686
+ return JSON.parse(cached);
687
+ }
688
+ } catch (error) {
689
+ this.#report(error);
690
+ }
691
+ let reading = this.#inFlight.get(key2);
692
+ if (reading === void 0) {
693
+ reading = { reads: 0, evictions: 0 };
694
+ this.#inFlight.set(key2, reading);
695
+ }
696
+ reading.reads++;
697
+ const evictionsBefore = reading.evictions;
698
+ try {
699
+ const stored = await this.#store.read(owner);
700
+ if (reading.evictions === evictionsBefore) {
701
+ try {
702
+ await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
703
+ } catch (error) {
704
+ this.#report(error);
705
+ }
706
+ if (reading.evictions !== evictionsBefore) {
707
+ try {
708
+ await this.#cache.delete(key2);
709
+ } catch (error) {
710
+ this.#report(error);
711
+ }
712
+ }
713
+ }
714
+ return stored;
715
+ } finally {
716
+ if (--reading.reads === 0) this.#inFlight.delete(key2);
717
+ }
718
+ }
719
+ /** {@inheritDoc SettingsStore.write} */
720
+ async write(owner, overrides, expectedVersion) {
721
+ try {
722
+ return await this.#store.write(owner, overrides, expectedVersion);
723
+ } finally {
724
+ await this.#evict(owner);
725
+ }
726
+ }
727
+ /** {@inheritDoc SettingsStore.delete} */
728
+ async delete(owner) {
729
+ try {
730
+ await this.#store.delete(owner);
731
+ } finally {
732
+ await this.#evict(owner);
733
+ }
734
+ }
735
+ async #evict(owner) {
736
+ const key2 = this.#key(owner);
737
+ const reading = this.#inFlight.get(key2);
738
+ if (reading !== void 0) reading.evictions++;
739
+ try {
740
+ await this.#cache.delete(key2);
741
+ } catch (error) {
742
+ this.#report(error);
743
+ }
744
+ }
745
+ #key(owner) {
746
+ return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
747
+ }
748
+ #report(error) {
749
+ this.#onError?.(error);
750
+ }
751
+ };
752
+
753
+ // src/memory-cache.ts
754
+ var MemoryCache = class {
755
+ #entries = /* @__PURE__ */ new Map();
756
+ #maxEntries;
757
+ /**
758
+ * @param options - The size limit.
759
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
760
+ */
761
+ constructor(options = {}) {
762
+ const { maxEntries = 1e3 } = options;
763
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
764
+ throw new RangeError(
765
+ "MemoryCache: maxEntries must be a positive integer."
766
+ );
767
+ }
768
+ this.#maxEntries = maxEntries;
769
+ }
770
+ /** {@inheritDoc SettingsCache.get} */
771
+ async get(key2) {
772
+ const entry = this.#entries.get(key2);
773
+ if (entry === void 0) return void 0;
774
+ if (entry.expiresAt <= Date.now()) {
775
+ this.#entries.delete(key2);
776
+ return void 0;
777
+ }
778
+ return entry.value;
779
+ }
780
+ /** {@inheritDoc SettingsCache.set} */
781
+ async set(key2, value, ttlSeconds) {
782
+ this.#entries.delete(key2);
783
+ this.#entries.set(key2, {
784
+ value,
785
+ expiresAt: Date.now() + ttlSeconds * 1e3
786
+ });
787
+ while (this.#entries.size > this.#maxEntries) {
788
+ const oldest = this.#entries.keys().next().value;
789
+ this.#entries.delete(oldest);
790
+ }
791
+ }
792
+ /** {@inheritDoc SettingsCache.delete} */
793
+ async delete(key2) {
794
+ this.#entries.delete(key2);
795
+ }
796
+ };
797
+
798
+ export { CachedStore, MemoryCache, MemoryStore, SealedStore, SettingsConflictError, SettingsDefinitionError, SettingsValidationError, createSettings, defineSettings };
package/dist/prisma.cjs CHANGED
@@ -26,7 +26,7 @@ var PrismaStore = class {
26
26
  if (expectedVersion === null) {
27
27
  try {
28
28
  const row = await this.#delegate.create({
29
- data: { ...key(owner), overrides: json }
29
+ data: { ...key(owner), overrides: json, version: startingVersion() }
30
30
  });
31
31
  return row.version;
32
32
  } catch (error) {
@@ -45,6 +45,9 @@ var PrismaStore = class {
45
45
  await this.#delegate.deleteMany({ where: key(owner) });
46
46
  }
47
47
  };
48
+ function startingVersion() {
49
+ return Math.floor(Math.random() * (2 ** 30 - 1)) + 1;
50
+ }
48
51
  function key(owner) {
49
52
  return { scope: owner.scope, ownerId: owner.id };
50
53
  }
package/dist/prisma.d.cts CHANGED
@@ -53,7 +53,7 @@ interface PrismaSettingsDelegate {
53
53
  /**
54
54
  * Creates a row, rejecting with an error whose `code` is `P2002` when one exists for the key.
55
55
  *
56
- * @param args - The new row's key and Overrides; its version comes from the column default.
56
+ * @param args - The new row's key, Overrides and starting version.
57
57
  * @returns The created row.
58
58
  */
59
59
  create(args: {
@@ -63,9 +63,10 @@ interface PrismaSettingsDelegate {
63
63
  overrides: {
64
64
  readonly [key: string]: JsonInput;
65
65
  };
66
+ version: number;
66
67
  };
67
68
  }): PromiseLike<{
68
- /** The `version` column, set by its default. */
69
+ /** The `version` column, as created. */
69
70
  version: number;
70
71
  }>;
71
72
  /**
@@ -113,19 +114,18 @@ interface PrismaSettingsDelegate {
113
114
  * An update only matches the row at the version the caller read, and a create that finds the row already there is a
114
115
  * conflict, so the retries of the settings created by `createSettings` work against a real database.
115
116
  *
116
- * Known limitation: `delete` removes the row, and a row created after it starts again at the column default of `1`,
117
- * so versions can repeat across a delete. A write that read version `n` of the old row before `reset()`, and stores
118
- * after other writes have recreated the row and brought it back to version `n`, overwrites the new row: the Overrides
119
- * written since the reset are lost and the old ones, with that write's change, come back. It takes a `reset()` and
120
- * another write to the same Owner both landing while that write is running. Avoiding it would need the version to
121
- * outlive the row, which the model doesn't keep, so call `reset()` when no other write to the Owner is expected, such
122
- * as when deleting the Owner.
117
+ * Known limitation: `delete` removes the row, and the model keeps no version once the row is gone. A row created
118
+ * after it starts at a random version in `[1, 2^30)` rather than the column default, so a write that read the old row
119
+ * before `reset()` is very unlikely, though not impossible, to match the new row's version. If it does match, it
120
+ * overwrites the new row: the Overrides written since the reset are lost and the old ones, with that write's change,
121
+ * come back. It takes a `reset()` and another write to the same Owner both landing while that write is running, so
122
+ * call `reset()` when no other write to the Owner is expected, such as when deleting the Owner.
123
123
  *
124
124
  * @example
125
125
  * import { PrismaStore } from "@rezwanul7/settings/prisma";
126
126
  *
127
127
  * const settings = createSettings({
128
- * definitions: { user },
128
+ * definitions: { user: userDefinition },
129
129
  * store: new PrismaStore({ delegate: prisma.setting }),
130
130
  * });
131
131
  */
package/dist/prisma.d.ts CHANGED
@@ -53,7 +53,7 @@ interface PrismaSettingsDelegate {
53
53
  /**
54
54
  * Creates a row, rejecting with an error whose `code` is `P2002` when one exists for the key.
55
55
  *
56
- * @param args - The new row's key and Overrides; its version comes from the column default.
56
+ * @param args - The new row's key, Overrides and starting version.
57
57
  * @returns The created row.
58
58
  */
59
59
  create(args: {
@@ -63,9 +63,10 @@ interface PrismaSettingsDelegate {
63
63
  overrides: {
64
64
  readonly [key: string]: JsonInput;
65
65
  };
66
+ version: number;
66
67
  };
67
68
  }): PromiseLike<{
68
- /** The `version` column, set by its default. */
69
+ /** The `version` column, as created. */
69
70
  version: number;
70
71
  }>;
71
72
  /**
@@ -113,19 +114,18 @@ interface PrismaSettingsDelegate {
113
114
  * An update only matches the row at the version the caller read, and a create that finds the row already there is a
114
115
  * conflict, so the retries of the settings created by `createSettings` work against a real database.
115
116
  *
116
- * Known limitation: `delete` removes the row, and a row created after it starts again at the column default of `1`,
117
- * so versions can repeat across a delete. A write that read version `n` of the old row before `reset()`, and stores
118
- * after other writes have recreated the row and brought it back to version `n`, overwrites the new row: the Overrides
119
- * written since the reset are lost and the old ones, with that write's change, come back. It takes a `reset()` and
120
- * another write to the same Owner both landing while that write is running. Avoiding it would need the version to
121
- * outlive the row, which the model doesn't keep, so call `reset()` when no other write to the Owner is expected, such
122
- * as when deleting the Owner.
117
+ * Known limitation: `delete` removes the row, and the model keeps no version once the row is gone. A row created
118
+ * after it starts at a random version in `[1, 2^30)` rather than the column default, so a write that read the old row
119
+ * before `reset()` is very unlikely, though not impossible, to match the new row's version. If it does match, it
120
+ * overwrites the new row: the Overrides written since the reset are lost and the old ones, with that write's change,
121
+ * come back. It takes a `reset()` and another write to the same Owner both landing while that write is running, so
122
+ * call `reset()` when no other write to the Owner is expected, such as when deleting the Owner.
123
123
  *
124
124
  * @example
125
125
  * import { PrismaStore } from "@rezwanul7/settings/prisma";
126
126
  *
127
127
  * const settings = createSettings({
128
- * definitions: { user },
128
+ * definitions: { user: userDefinition },
129
129
  * store: new PrismaStore({ delegate: prisma.setting }),
130
130
  * });
131
131
  */
package/dist/prisma.js CHANGED
@@ -24,7 +24,7 @@ var PrismaStore = class {
24
24
  if (expectedVersion === null) {
25
25
  try {
26
26
  const row = await this.#delegate.create({
27
- data: { ...key(owner), overrides: json }
27
+ data: { ...key(owner), overrides: json, version: startingVersion() }
28
28
  });
29
29
  return row.version;
30
30
  } catch (error) {
@@ -43,6 +43,9 @@ var PrismaStore = class {
43
43
  await this.#delegate.deleteMany({ where: key(owner) });
44
44
  }
45
45
  };
46
+ function startingVersion() {
47
+ return Math.floor(Math.random() * (2 ** 30 - 1)) + 1;
48
+ }
46
49
  function key(owner) {
47
50
  return { scope: owner.scope, ownerId: owner.id };
48
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezwanul7/settings",
3
- "version": "1.0.0",
3
+ "version": "2.0.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",