@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/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) {
@@ -468,6 +500,7 @@ function asJson(overrides) {
468
500
  }
469
501
 
470
502
  // src/sealed-store.ts
503
+ var DISCARD_RETRIES = 3;
471
504
  function defaultIsSealed(value) {
472
505
  return typeof value === "string" && value.startsWith("vault:");
473
506
  }
@@ -479,22 +512,46 @@ var SealedStore = class {
479
512
  #vault;
480
513
  #paths;
481
514
  /**
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.
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.
484
518
  */
485
519
  constructor(options) {
486
520
  this.#store = options.store;
487
521
  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) {
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)) {
493
527
  throw new TypeError(
494
- `SealedStore: path "${inner}" is inside path "${path}".`
528
+ `SealedStore: Scope "${scope}" in paths isn't in definitions.`
495
529
  );
496
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.");
497
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) ?? [];
498
555
  }
499
556
  /** {@inheritDoc SettingsStore.read} */
500
557
  async read(owner) {
@@ -502,7 +559,7 @@ var SealedStore = class {
502
559
  if (stored === null) return null;
503
560
  const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
504
561
  let overrides = stored.overrides;
505
- for (const path of this.#paths) {
562
+ for (const path of this.#pathsOf(owner)) {
506
563
  const value = getAt(overrides, path);
507
564
  if (!isSealed(value)) continue;
508
565
  const plaintext = await this.#vault.decryptJson(
@@ -515,18 +572,74 @@ var SealedStore = class {
515
572
  }
516
573
  /** {@inheritDoc SettingsStore.write} */
517
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) {
518
586
  let sealed = overrides;
519
- for (const path of this.#paths) {
587
+ for (const path of this.#pathsOf(owner)) {
520
588
  if (!hasAt(sealed, path)) continue;
521
589
  const value = getAt(sealed, path);
522
- if (value === void 0) continue;
590
+ if (value === void 0 || skip(value)) continue;
523
591
  sealed = setAt(
524
592
  sealed,
525
593
  path,
526
594
  await this.#vault.encryptJson(value, contextOf(owner, path))
527
595
  );
528
596
  }
529
- return this.#store.write(owner, sealed, expectedVersion);
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);
530
643
  }
531
644
  /** {@inheritDoc SettingsStore.delete} */
532
645
  async delete(owner) {
@@ -541,6 +654,12 @@ var CachedStore = class {
541
654
  #ttl;
542
655
  #prefix;
543
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();
544
663
  /**
545
664
  * @param options - The Store to wrap, the cache and the TTL.
546
665
  * @throws `RangeError` when `ttl` isn't a positive number.
@@ -553,7 +672,10 @@ var CachedStore = class {
553
672
  this.#cache = options.cache;
554
673
  this.#ttl = options.ttl;
555
674
  this.#prefix = options.prefix ?? "settings";
556
- this.#onError = options.onError;
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
+ );
557
679
  }
558
680
  /** {@inheritDoc SettingsStore.read} */
559
681
  async read(owner) {
@@ -566,13 +688,33 @@ var CachedStore = class {
566
688
  } catch (error) {
567
689
  this.#report(error);
568
690
  }
569
- const stored = await this.#store.read(owner);
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;
570
698
  try {
571
- await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
572
- } catch (error) {
573
- this.#report(error);
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);
574
717
  }
575
- return stored;
576
718
  }
577
719
  /** {@inheritDoc SettingsStore.write} */
578
720
  async write(owner, overrides, expectedVersion) {
@@ -591,8 +733,11 @@ var CachedStore = class {
591
733
  }
592
734
  }
593
735
  async #evict(owner) {
736
+ const key2 = this.#key(owner);
737
+ const reading = this.#inFlight.get(key2);
738
+ if (reading !== void 0) reading.evictions++;
594
739
  try {
595
- await this.#cache.delete(this.#key(owner));
740
+ await this.#cache.delete(key2);
596
741
  } catch (error) {
597
742
  this.#report(error);
598
743
  }
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.1.0",
3
+ "version": "2.0.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",