@rezwanul7/settings 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -48
- package/dist/index.cjs +165 -20
- package/dist/index.d.cts +124 -29
- package/dist/index.d.ts +124 -29
- package/dist/index.js +165 -20
- package/dist/prisma.cjs +4 -1
- package/dist/prisma.d.cts +10 -10
- package/dist/prisma.d.ts +10 -10
- package/dist/prisma.js +4 -1
- package/package.json +1 -1
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,
|
|
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
|
|
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
|
-
|
|
489
|
-
for (const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (
|
|
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:
|
|
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.#
|
|
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.#
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.#
|
|
572
|
-
|
|
573
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* written since the reset are lost and the old ones, with that write's change,
|
|
120
|
-
* another write to the same Owner both landing while that write is running
|
|
121
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* written since the reset are lost and the old ones, with that write's change,
|
|
120
|
-
* another write to the same Owner both landing while that write is running
|
|
121
|
-
*
|
|
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
|
}
|