@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/README.md +174 -60
- package/dist/index.cjs +332 -1
- package/dist/index.d.cts +278 -14
- package/dist/index.d.ts +278 -14
- package/dist/index.js +330 -2
- 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 +2 -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) {
|
|
@@ -467,4 +499,300 @@ function asJson(overrides) {
|
|
|
467
499
|
return JSON.parse(JSON.stringify(overrides));
|
|
468
500
|
}
|
|
469
501
|
|
|
470
|
-
|
|
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
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rezwanul7/settings",
|
|
3
|
-
"version": "
|
|
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",
|