@rezwanul7/settings 1.0.0 → 1.1.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 +69 -24
- package/dist/index.cjs +186 -0
- package/dist/index.d.cts +170 -1
- package/dist/index.d.ts +170 -1
- package/dist/index.js +184 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -307,15 +307,17 @@ again later. `reset()` deletes without a version check. `MemoryStore` keeps an O
|
|
|
307
307
|
|
|
308
308
|
## Secret settings
|
|
309
309
|
|
|
310
|
-
|
|
311
|
-
[`@rezwanul7/secret-vault`](../secret-vault/README.md)
|
|
312
|
-
|
|
310
|
+
Keep a secret setting, such as an SMTP password, sealed by wrapping your Store in `SealedStore` with a
|
|
311
|
+
[`@rezwanul7/secret-vault`](../secret-vault/README.md) `Vault`. List the paths to seal; you call `set` and `get` with
|
|
312
|
+
plain values, the schema validates the Plaintext, and the wrapped Store only ever holds a Sealed value there. This
|
|
313
|
+
package doesn't depend on Secret Vault: `vault` only needs `encryptJson`, `decryptJson` and, optionally, `isSealed`
|
|
314
|
+
(without it, a string starting with `vault:` counts as sealed, which is what a `Vault` writes).
|
|
313
315
|
|
|
314
316
|
```ts
|
|
315
317
|
import {
|
|
316
318
|
createSettings,
|
|
317
319
|
defineSettings,
|
|
318
|
-
|
|
320
|
+
SealedStore,
|
|
319
321
|
} from "@rezwanul7/settings";
|
|
320
322
|
import { PrismaStore } from "@rezwanul7/settings/prisma";
|
|
321
323
|
import { z } from "zod";
|
|
@@ -326,8 +328,7 @@ const organization = defineSettings({
|
|
|
326
328
|
schema: z.object({
|
|
327
329
|
smtp: z.object({
|
|
328
330
|
host: z.string(),
|
|
329
|
-
|
|
330
|
-
password: z.string().startsWith("vault:").nullable(),
|
|
331
|
+
password: z.string().min(1).nullable(),
|
|
331
332
|
}),
|
|
332
333
|
}),
|
|
333
334
|
defaults: { smtp: { host: "localhost", password: null } },
|
|
@@ -335,30 +336,74 @@ const organization = defineSettings({
|
|
|
335
336
|
|
|
336
337
|
const settings = createSettings({
|
|
337
338
|
definitions: { organization },
|
|
338
|
-
store: new
|
|
339
|
+
store: new SealedStore({
|
|
340
|
+
store: new PrismaStore({ delegate: prisma.setting }),
|
|
341
|
+
vault,
|
|
342
|
+
paths: ["smtp.password"],
|
|
343
|
+
}),
|
|
339
344
|
});
|
|
340
345
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
346
|
+
await settings.scope("organization", orgId).set("smtp.password", "hunter2");
|
|
347
|
+
const password = await settings
|
|
348
|
+
.scope("organization", orgId)
|
|
349
|
+
.get("smtp.password"); // "hunter2"
|
|
350
|
+
```
|
|
344
351
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
352
|
+
- Each Sealed value is bound to its Owner and path (Context `settings:` plus `JSON.stringify([scope, id, path])`), so
|
|
353
|
+
one copied to another Owner or path doesn't open. A script that moves a Sealed setting must open it and seal it
|
|
354
|
+
again.
|
|
355
|
+
- A path may name an object, which is sealed whole as JSON. No path may be inside another.
|
|
356
|
+
- A Sealed value that doesn't open, such as after a wrong Vault secret, makes the read **throw**. It is never ignored
|
|
357
|
+
as a stale Override, because the next write would replace the secret with its default.
|
|
358
|
+
- Every write seals again with the Vault's current secret, so a Rotation finishes for an Owner the next time it is
|
|
359
|
+
written.
|
|
360
|
+
- Adding a path to `paths` on a deployment that already has plain values there is safe: they read as before and are
|
|
361
|
+
sealed on each Owner's next write. Values at unlisted paths are never opened.
|
|
362
|
+
- The Effective settings now hold the **Plaintext**. Don't log them or pass them to the client; read the secret on the
|
|
363
|
+
server, only where it is used.
|
|
364
|
+
- Switching from values you sealed by hand: their Context differs, so open each with your old Context once and write
|
|
365
|
+
the Plaintext back through the settings with a script; `SealedStore` then seals it under its own Context.
|
|
366
|
+
|
|
367
|
+
## Caching reads
|
|
368
|
+
|
|
369
|
+
`CachedStore` keeps what the Store it wraps returns for `ttl` seconds, so reading one Owner's settings in several
|
|
370
|
+
requests doesn't query the database each time. An Owner with nothing stored is cached too. Every write, conflict and
|
|
371
|
+
delete evicts the Owner, so a write on this instance is read back at once and two concurrent writes both take effect.
|
|
350
372
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
:
|
|
357
|
-
}
|
|
373
|
+
```ts
|
|
374
|
+
import { CachedStore, MemoryCache, SealedStore } from "@rezwanul7/settings";
|
|
375
|
+
|
|
376
|
+
const store = new SealedStore({
|
|
377
|
+
store: new CachedStore({
|
|
378
|
+
store: new PrismaStore({ delegate: prisma.setting }),
|
|
379
|
+
cache: new MemoryCache({ maxEntries: 5000 }),
|
|
380
|
+
ttl: 60,
|
|
381
|
+
}),
|
|
382
|
+
vault,
|
|
383
|
+
paths: ["smtp.password"],
|
|
384
|
+
});
|
|
358
385
|
```
|
|
359
386
|
|
|
360
|
-
|
|
361
|
-
|
|
387
|
+
Nest `SealedStore` outermost so the cache only holds Sealed values. A cache that throws is passed to the optional
|
|
388
|
+
`onError` and the Store answers instead; a failing `delete` leaves the entry to expire with its TTL.
|
|
389
|
+
|
|
390
|
+
`MemoryCache` is per process. With several instances, another instance can serve Overrides up to `ttl` seconds old
|
|
391
|
+
after a write elsewhere. Share a cache to avoid that; any object with three string methods fits `SettingsCache`:
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
import type { SettingsCache } from "@rezwanul7/settings";
|
|
395
|
+
import { redis } from "./redis";
|
|
396
|
+
|
|
397
|
+
const redisCache: SettingsCache = {
|
|
398
|
+
get: async (key) => (await redis.get(key)) ?? undefined,
|
|
399
|
+
set: async (key, value, ttlSeconds) => {
|
|
400
|
+
await redis.set(key, value, "EX", Math.ceil(ttlSeconds));
|
|
401
|
+
},
|
|
402
|
+
delete: async (key) => {
|
|
403
|
+
await redis.del(key);
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
```
|
|
362
407
|
|
|
363
408
|
## Errors
|
|
364
409
|
|
package/dist/index.cjs
CHANGED
|
@@ -469,7 +469,193 @@ function asJson(overrides) {
|
|
|
469
469
|
return JSON.parse(JSON.stringify(overrides));
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
// src/sealed-store.ts
|
|
473
|
+
function defaultIsSealed(value) {
|
|
474
|
+
return typeof value === "string" && value.startsWith("vault:");
|
|
475
|
+
}
|
|
476
|
+
function contextOf(owner, path) {
|
|
477
|
+
return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
|
|
478
|
+
}
|
|
479
|
+
var SealedStore = class {
|
|
480
|
+
#store;
|
|
481
|
+
#vault;
|
|
482
|
+
#paths;
|
|
483
|
+
/**
|
|
484
|
+
* @param options - The Store to wrap, the Vault and the sealed paths.
|
|
485
|
+
* @throws `TypeError` when a path is empty or inside another path.
|
|
486
|
+
*/
|
|
487
|
+
constructor(options) {
|
|
488
|
+
this.#store = options.store;
|
|
489
|
+
this.#vault = options.vault;
|
|
490
|
+
this.#paths = [...new Set(options.paths)];
|
|
491
|
+
for (const path of this.#paths) {
|
|
492
|
+
if (path === "") throw new TypeError("SealedStore: a path is empty.");
|
|
493
|
+
const inner = this.#paths.find((p) => p.startsWith(`${path}.`));
|
|
494
|
+
if (inner !== void 0) {
|
|
495
|
+
throw new TypeError(
|
|
496
|
+
`SealedStore: path "${inner}" is inside path "${path}".`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
502
|
+
async read(owner) {
|
|
503
|
+
const stored = await this.#store.read(owner);
|
|
504
|
+
if (stored === null) return null;
|
|
505
|
+
const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
|
|
506
|
+
let overrides = stored.overrides;
|
|
507
|
+
for (const path of this.#paths) {
|
|
508
|
+
const value = getAt(overrides, path);
|
|
509
|
+
if (!isSealed(value)) continue;
|
|
510
|
+
const plaintext = await this.#vault.decryptJson(
|
|
511
|
+
value,
|
|
512
|
+
contextOf(owner, path)
|
|
513
|
+
);
|
|
514
|
+
overrides = setAt(overrides, path, plaintext);
|
|
515
|
+
}
|
|
516
|
+
return { overrides, version: stored.version };
|
|
517
|
+
}
|
|
518
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
519
|
+
async write(owner, overrides, expectedVersion) {
|
|
520
|
+
let sealed = overrides;
|
|
521
|
+
for (const path of this.#paths) {
|
|
522
|
+
if (!hasAt(sealed, path)) continue;
|
|
523
|
+
const value = getAt(sealed, path);
|
|
524
|
+
if (value === void 0) continue;
|
|
525
|
+
sealed = setAt(
|
|
526
|
+
sealed,
|
|
527
|
+
path,
|
|
528
|
+
await this.#vault.encryptJson(value, contextOf(owner, path))
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return this.#store.write(owner, sealed, expectedVersion);
|
|
532
|
+
}
|
|
533
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
534
|
+
async delete(owner) {
|
|
535
|
+
await this.#store.delete(owner);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// src/cached-store.ts
|
|
540
|
+
var CachedStore = class {
|
|
541
|
+
#store;
|
|
542
|
+
#cache;
|
|
543
|
+
#ttl;
|
|
544
|
+
#prefix;
|
|
545
|
+
#onError;
|
|
546
|
+
/**
|
|
547
|
+
* @param options - The Store to wrap, the cache and the TTL.
|
|
548
|
+
* @throws `RangeError` when `ttl` isn't a positive number.
|
|
549
|
+
*/
|
|
550
|
+
constructor(options) {
|
|
551
|
+
if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
|
|
552
|
+
throw new RangeError("CachedStore: ttl must be a positive number.");
|
|
553
|
+
}
|
|
554
|
+
this.#store = options.store;
|
|
555
|
+
this.#cache = options.cache;
|
|
556
|
+
this.#ttl = options.ttl;
|
|
557
|
+
this.#prefix = options.prefix ?? "settings";
|
|
558
|
+
this.#onError = options.onError;
|
|
559
|
+
}
|
|
560
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
561
|
+
async read(owner) {
|
|
562
|
+
const key2 = this.#key(owner);
|
|
563
|
+
try {
|
|
564
|
+
const cached = await this.#cache.get(key2);
|
|
565
|
+
if (cached !== void 0) {
|
|
566
|
+
return JSON.parse(cached);
|
|
567
|
+
}
|
|
568
|
+
} catch (error) {
|
|
569
|
+
this.#report(error);
|
|
570
|
+
}
|
|
571
|
+
const stored = await this.#store.read(owner);
|
|
572
|
+
try {
|
|
573
|
+
await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
this.#report(error);
|
|
576
|
+
}
|
|
577
|
+
return stored;
|
|
578
|
+
}
|
|
579
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
580
|
+
async write(owner, overrides, expectedVersion) {
|
|
581
|
+
try {
|
|
582
|
+
return await this.#store.write(owner, overrides, expectedVersion);
|
|
583
|
+
} finally {
|
|
584
|
+
await this.#evict(owner);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
588
|
+
async delete(owner) {
|
|
589
|
+
try {
|
|
590
|
+
await this.#store.delete(owner);
|
|
591
|
+
} finally {
|
|
592
|
+
await this.#evict(owner);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async #evict(owner) {
|
|
596
|
+
try {
|
|
597
|
+
await this.#cache.delete(this.#key(owner));
|
|
598
|
+
} catch (error) {
|
|
599
|
+
this.#report(error);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
#key(owner) {
|
|
603
|
+
return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
|
|
604
|
+
}
|
|
605
|
+
#report(error) {
|
|
606
|
+
this.#onError?.(error);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
// src/memory-cache.ts
|
|
611
|
+
var MemoryCache = class {
|
|
612
|
+
#entries = /* @__PURE__ */ new Map();
|
|
613
|
+
#maxEntries;
|
|
614
|
+
/**
|
|
615
|
+
* @param options - The size limit.
|
|
616
|
+
* @throws `RangeError` when `maxEntries` isn't a positive integer.
|
|
617
|
+
*/
|
|
618
|
+
constructor(options = {}) {
|
|
619
|
+
const { maxEntries = 1e3 } = options;
|
|
620
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
621
|
+
throw new RangeError(
|
|
622
|
+
"MemoryCache: maxEntries must be a positive integer."
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
this.#maxEntries = maxEntries;
|
|
626
|
+
}
|
|
627
|
+
/** {@inheritDoc SettingsCache.get} */
|
|
628
|
+
async get(key2) {
|
|
629
|
+
const entry = this.#entries.get(key2);
|
|
630
|
+
if (entry === void 0) return void 0;
|
|
631
|
+
if (entry.expiresAt <= Date.now()) {
|
|
632
|
+
this.#entries.delete(key2);
|
|
633
|
+
return void 0;
|
|
634
|
+
}
|
|
635
|
+
return entry.value;
|
|
636
|
+
}
|
|
637
|
+
/** {@inheritDoc SettingsCache.set} */
|
|
638
|
+
async set(key2, value, ttlSeconds) {
|
|
639
|
+
this.#entries.delete(key2);
|
|
640
|
+
this.#entries.set(key2, {
|
|
641
|
+
value,
|
|
642
|
+
expiresAt: Date.now() + ttlSeconds * 1e3
|
|
643
|
+
});
|
|
644
|
+
while (this.#entries.size > this.#maxEntries) {
|
|
645
|
+
const oldest = this.#entries.keys().next().value;
|
|
646
|
+
this.#entries.delete(oldest);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/** {@inheritDoc SettingsCache.delete} */
|
|
650
|
+
async delete(key2) {
|
|
651
|
+
this.#entries.delete(key2);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
exports.CachedStore = CachedStore;
|
|
656
|
+
exports.MemoryCache = MemoryCache;
|
|
472
657
|
exports.MemoryStore = MemoryStore;
|
|
658
|
+
exports.SealedStore = SealedStore;
|
|
473
659
|
exports.SettingsConflictError = SettingsConflictError;
|
|
474
660
|
exports.SettingsDefinitionError = SettingsDefinitionError;
|
|
475
661
|
exports.SettingsValidationError = SettingsValidationError;
|
package/dist/index.d.cts
CHANGED
|
@@ -314,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
|
|
|
314
314
|
delete(owner: Owner): Promise<void>;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The part of a Secret Vault `Vault` that {@link SealedStore} uses, so this package doesn't depend on Secret Vault.
|
|
319
|
+
* A `Vault` fits.
|
|
320
|
+
*/
|
|
321
|
+
interface SealingVault {
|
|
322
|
+
/**
|
|
323
|
+
* Seals a JSON value.
|
|
324
|
+
*
|
|
325
|
+
* @param value - The value to seal.
|
|
326
|
+
* @param context - Where the Sealed value is stored.
|
|
327
|
+
* @returns The Sealed value.
|
|
328
|
+
*/
|
|
329
|
+
encryptJson(value: unknown, context: string): Promise<string>;
|
|
330
|
+
/**
|
|
331
|
+
* Opens a Sealed value made by `encryptJson`; rejects when it doesn't open.
|
|
332
|
+
*
|
|
333
|
+
* @param sealed - The Sealed value.
|
|
334
|
+
* @param context - The Context it was sealed under.
|
|
335
|
+
* @returns The Plaintext value.
|
|
336
|
+
*/
|
|
337
|
+
decryptJson(sealed: string, context: string): Promise<unknown>;
|
|
338
|
+
/**
|
|
339
|
+
* Whether a stored value is a Sealed value rather than plain text still stored at a listed path. Optional: a Secret
|
|
340
|
+
* Vault `Vault` has `isSealed` only as a static method, so without it `SealedStore` treats a string starting with
|
|
341
|
+
* `vault:` as sealed.
|
|
342
|
+
*
|
|
343
|
+
* @param value - A stored value.
|
|
344
|
+
*/
|
|
345
|
+
isSealed?(value: unknown): boolean;
|
|
346
|
+
}
|
|
347
|
+
/** Options of {@link SealedStore}. */
|
|
348
|
+
interface SealedStoreOptions {
|
|
349
|
+
/** The Store that keeps the Sealed values. */
|
|
350
|
+
readonly store: SettingsStore;
|
|
351
|
+
/** Seals and opens values; a Secret Vault `Vault` fits. */
|
|
352
|
+
readonly vault: SealingVault;
|
|
353
|
+
/**
|
|
354
|
+
* Dot paths into the settings to keep sealed, such as `"smtp.password"`. A path may name an object, which is sealed
|
|
355
|
+
* whole as JSON. No path may be inside another.
|
|
356
|
+
*/
|
|
357
|
+
readonly paths: readonly string[];
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A Store that keeps the Overrides at the listed paths as Sealed values in the Store it wraps, and hands the core
|
|
361
|
+
* their Plaintext. The schema validates the Plaintext, and the app calls `set` and `get` with plain values.
|
|
362
|
+
*
|
|
363
|
+
* Every write seals again with the Vault's current secret, which finishes a Rotation for that Owner. A Sealed value
|
|
364
|
+
* that doesn't open makes the read throw: it is never ignored as a stale Override, because the next write would
|
|
365
|
+
* replace the secret with its default. A plain value at a listed path reads as it is and is sealed on the Owner's next
|
|
366
|
+
* write. Values at unlisted paths are never opened.
|
|
367
|
+
*
|
|
368
|
+
* Nest it outermost, around a {@link CachedStore}, so the cache holds only Sealed values.
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* const store = new SealedStore({
|
|
372
|
+
* store: new PrismaStore({ delegate: prisma.setting }),
|
|
373
|
+
* vault,
|
|
374
|
+
* paths: ["smtp.password"],
|
|
375
|
+
* });
|
|
376
|
+
*/
|
|
377
|
+
declare class SealedStore implements SettingsStore {
|
|
378
|
+
#private;
|
|
379
|
+
/**
|
|
380
|
+
* @param options - The Store to wrap, the Vault and the sealed paths.
|
|
381
|
+
* @throws `TypeError` when a path is empty or inside another path.
|
|
382
|
+
*/
|
|
383
|
+
constructor(options: SealedStoreOptions);
|
|
384
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
385
|
+
read(owner: Owner): Promise<StoredOverrides | null>;
|
|
386
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
387
|
+
write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
|
|
388
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
389
|
+
delete(owner: Owner): Promise<void>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A cache of strings with a time to live, which {@link CachedStore} keeps Overrides in. {@link MemoryCache} fits;
|
|
394
|
+
* so does a thin wrapper around Redis or Keyv.
|
|
395
|
+
*/
|
|
396
|
+
interface SettingsCache {
|
|
397
|
+
/**
|
|
398
|
+
* Reads an entry.
|
|
399
|
+
*
|
|
400
|
+
* @param key - The cache key.
|
|
401
|
+
* @returns The value, or `undefined` when there is none or it expired.
|
|
402
|
+
*/
|
|
403
|
+
get(key: string): Promise<string | undefined>;
|
|
404
|
+
/**
|
|
405
|
+
* Stores an entry, replacing any under the key.
|
|
406
|
+
*
|
|
407
|
+
* @param key - The cache key.
|
|
408
|
+
* @param value - The string to keep.
|
|
409
|
+
* @param ttlSeconds - Seconds until the entry expires.
|
|
410
|
+
*/
|
|
411
|
+
set(key: string, value: string, ttlSeconds: number): Promise<void>;
|
|
412
|
+
/**
|
|
413
|
+
* Removes an entry; removing a missing one does nothing.
|
|
414
|
+
*
|
|
415
|
+
* @param key - The cache key.
|
|
416
|
+
*/
|
|
417
|
+
delete(key: string): Promise<void>;
|
|
418
|
+
}
|
|
419
|
+
/** Options of {@link CachedStore}. */
|
|
420
|
+
interface CachedStoreOptions {
|
|
421
|
+
/** The Store to read through. */
|
|
422
|
+
readonly store: SettingsStore;
|
|
423
|
+
/** Where to keep what the Store returns. */
|
|
424
|
+
readonly cache: SettingsCache;
|
|
425
|
+
/** Seconds an entry stays cached. */
|
|
426
|
+
readonly ttl: number;
|
|
427
|
+
/** Starts every cache key. Defaults to `"settings"`. */
|
|
428
|
+
readonly prefix?: string;
|
|
429
|
+
/** Called with an error a cache method threw; the Store then answers instead. */
|
|
430
|
+
readonly onError?: (error: unknown) => void;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* A Store that keeps what the Store it wraps returns in a cache for `ttl` seconds, an Owner with nothing stored
|
|
434
|
+
* included. Every write, conflict and delete evicts the Owner, so a write on this instance is read back at once and a
|
|
435
|
+
* conflicting write retries against fresh Overrides. A cache that throws is reported to `onError` and the Store
|
|
436
|
+
* answers instead.
|
|
437
|
+
*
|
|
438
|
+
* With a per-process cache and several instances, another instance can read Overrides up to `ttl` seconds old. Share
|
|
439
|
+
* a cache, such as Redis, to avoid it.
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* const store = new CachedStore({ store: prismaStore, cache: new MemoryCache(), ttl: 60 });
|
|
443
|
+
*/
|
|
444
|
+
declare class CachedStore implements SettingsStore {
|
|
445
|
+
#private;
|
|
446
|
+
/**
|
|
447
|
+
* @param options - The Store to wrap, the cache and the TTL.
|
|
448
|
+
* @throws `RangeError` when `ttl` isn't a positive number.
|
|
449
|
+
*/
|
|
450
|
+
constructor(options: CachedStoreOptions);
|
|
451
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
452
|
+
read(owner: Owner): Promise<StoredOverrides | null>;
|
|
453
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
454
|
+
write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
|
|
455
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
456
|
+
delete(owner: Owner): Promise<void>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Options of {@link MemoryCache}. */
|
|
460
|
+
interface MemoryCacheOptions {
|
|
461
|
+
/** Most entries kept; the oldest are evicted past it. Defaults to 1000. */
|
|
462
|
+
readonly maxEntries?: number;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* A {@link SettingsCache} in process memory. Entries expire after their TTL and the oldest are evicted past
|
|
466
|
+
* `maxEntries`. It is per process: instances don't share it.
|
|
467
|
+
*
|
|
468
|
+
* @example
|
|
469
|
+
* const cache = new MemoryCache({ maxEntries: 5000 });
|
|
470
|
+
*/
|
|
471
|
+
declare class MemoryCache implements SettingsCache {
|
|
472
|
+
#private;
|
|
473
|
+
/**
|
|
474
|
+
* @param options - The size limit.
|
|
475
|
+
* @throws `RangeError` when `maxEntries` isn't a positive integer.
|
|
476
|
+
*/
|
|
477
|
+
constructor(options?: MemoryCacheOptions);
|
|
478
|
+
/** {@inheritDoc SettingsCache.get} */
|
|
479
|
+
get(key: string): Promise<string | undefined>;
|
|
480
|
+
/** {@inheritDoc SettingsCache.set} */
|
|
481
|
+
set(key: string, value: string, ttlSeconds: number): Promise<void>;
|
|
482
|
+
/** {@inheritDoc SettingsCache.delete} */
|
|
483
|
+
delete(key: string): Promise<void>;
|
|
484
|
+
}
|
|
485
|
+
|
|
317
486
|
/**
|
|
318
487
|
* A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
|
|
319
488
|
* Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
|
|
@@ -358,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
|
|
|
358
527
|
constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
|
|
359
528
|
}
|
|
360
529
|
|
|
361
|
-
export { MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
|
|
530
|
+
export { CachedStore, type CachedStoreOptions, MemoryCache, type MemoryCacheOptions, MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SealedStore, type SealedStoreOptions, type SealingVault, type SettingsCache, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
|
package/dist/index.d.ts
CHANGED
|
@@ -314,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
|
|
|
314
314
|
delete(owner: Owner): Promise<void>;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The part of a Secret Vault `Vault` that {@link SealedStore} uses, so this package doesn't depend on Secret Vault.
|
|
319
|
+
* A `Vault` fits.
|
|
320
|
+
*/
|
|
321
|
+
interface SealingVault {
|
|
322
|
+
/**
|
|
323
|
+
* Seals a JSON value.
|
|
324
|
+
*
|
|
325
|
+
* @param value - The value to seal.
|
|
326
|
+
* @param context - Where the Sealed value is stored.
|
|
327
|
+
* @returns The Sealed value.
|
|
328
|
+
*/
|
|
329
|
+
encryptJson(value: unknown, context: string): Promise<string>;
|
|
330
|
+
/**
|
|
331
|
+
* Opens a Sealed value made by `encryptJson`; rejects when it doesn't open.
|
|
332
|
+
*
|
|
333
|
+
* @param sealed - The Sealed value.
|
|
334
|
+
* @param context - The Context it was sealed under.
|
|
335
|
+
* @returns The Plaintext value.
|
|
336
|
+
*/
|
|
337
|
+
decryptJson(sealed: string, context: string): Promise<unknown>;
|
|
338
|
+
/**
|
|
339
|
+
* Whether a stored value is a Sealed value rather than plain text still stored at a listed path. Optional: a Secret
|
|
340
|
+
* Vault `Vault` has `isSealed` only as a static method, so without it `SealedStore` treats a string starting with
|
|
341
|
+
* `vault:` as sealed.
|
|
342
|
+
*
|
|
343
|
+
* @param value - A stored value.
|
|
344
|
+
*/
|
|
345
|
+
isSealed?(value: unknown): boolean;
|
|
346
|
+
}
|
|
347
|
+
/** Options of {@link SealedStore}. */
|
|
348
|
+
interface SealedStoreOptions {
|
|
349
|
+
/** The Store that keeps the Sealed values. */
|
|
350
|
+
readonly store: SettingsStore;
|
|
351
|
+
/** Seals and opens values; a Secret Vault `Vault` fits. */
|
|
352
|
+
readonly vault: SealingVault;
|
|
353
|
+
/**
|
|
354
|
+
* Dot paths into the settings to keep sealed, such as `"smtp.password"`. A path may name an object, which is sealed
|
|
355
|
+
* whole as JSON. No path may be inside another.
|
|
356
|
+
*/
|
|
357
|
+
readonly paths: readonly string[];
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A Store that keeps the Overrides at the listed paths as Sealed values in the Store it wraps, and hands the core
|
|
361
|
+
* their Plaintext. The schema validates the Plaintext, and the app calls `set` and `get` with plain values.
|
|
362
|
+
*
|
|
363
|
+
* Every write seals again with the Vault's current secret, which finishes a Rotation for that Owner. A Sealed value
|
|
364
|
+
* that doesn't open makes the read throw: it is never ignored as a stale Override, because the next write would
|
|
365
|
+
* replace the secret with its default. A plain value at a listed path reads as it is and is sealed on the Owner's next
|
|
366
|
+
* write. Values at unlisted paths are never opened.
|
|
367
|
+
*
|
|
368
|
+
* Nest it outermost, around a {@link CachedStore}, so the cache holds only Sealed values.
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* const store = new SealedStore({
|
|
372
|
+
* store: new PrismaStore({ delegate: prisma.setting }),
|
|
373
|
+
* vault,
|
|
374
|
+
* paths: ["smtp.password"],
|
|
375
|
+
* });
|
|
376
|
+
*/
|
|
377
|
+
declare class SealedStore implements SettingsStore {
|
|
378
|
+
#private;
|
|
379
|
+
/**
|
|
380
|
+
* @param options - The Store to wrap, the Vault and the sealed paths.
|
|
381
|
+
* @throws `TypeError` when a path is empty or inside another path.
|
|
382
|
+
*/
|
|
383
|
+
constructor(options: SealedStoreOptions);
|
|
384
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
385
|
+
read(owner: Owner): Promise<StoredOverrides | null>;
|
|
386
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
387
|
+
write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
|
|
388
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
389
|
+
delete(owner: Owner): Promise<void>;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A cache of strings with a time to live, which {@link CachedStore} keeps Overrides in. {@link MemoryCache} fits;
|
|
394
|
+
* so does a thin wrapper around Redis or Keyv.
|
|
395
|
+
*/
|
|
396
|
+
interface SettingsCache {
|
|
397
|
+
/**
|
|
398
|
+
* Reads an entry.
|
|
399
|
+
*
|
|
400
|
+
* @param key - The cache key.
|
|
401
|
+
* @returns The value, or `undefined` when there is none or it expired.
|
|
402
|
+
*/
|
|
403
|
+
get(key: string): Promise<string | undefined>;
|
|
404
|
+
/**
|
|
405
|
+
* Stores an entry, replacing any under the key.
|
|
406
|
+
*
|
|
407
|
+
* @param key - The cache key.
|
|
408
|
+
* @param value - The string to keep.
|
|
409
|
+
* @param ttlSeconds - Seconds until the entry expires.
|
|
410
|
+
*/
|
|
411
|
+
set(key: string, value: string, ttlSeconds: number): Promise<void>;
|
|
412
|
+
/**
|
|
413
|
+
* Removes an entry; removing a missing one does nothing.
|
|
414
|
+
*
|
|
415
|
+
* @param key - The cache key.
|
|
416
|
+
*/
|
|
417
|
+
delete(key: string): Promise<void>;
|
|
418
|
+
}
|
|
419
|
+
/** Options of {@link CachedStore}. */
|
|
420
|
+
interface CachedStoreOptions {
|
|
421
|
+
/** The Store to read through. */
|
|
422
|
+
readonly store: SettingsStore;
|
|
423
|
+
/** Where to keep what the Store returns. */
|
|
424
|
+
readonly cache: SettingsCache;
|
|
425
|
+
/** Seconds an entry stays cached. */
|
|
426
|
+
readonly ttl: number;
|
|
427
|
+
/** Starts every cache key. Defaults to `"settings"`. */
|
|
428
|
+
readonly prefix?: string;
|
|
429
|
+
/** Called with an error a cache method threw; the Store then answers instead. */
|
|
430
|
+
readonly onError?: (error: unknown) => void;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* A Store that keeps what the Store it wraps returns in a cache for `ttl` seconds, an Owner with nothing stored
|
|
434
|
+
* included. Every write, conflict and delete evicts the Owner, so a write on this instance is read back at once and a
|
|
435
|
+
* conflicting write retries against fresh Overrides. A cache that throws is reported to `onError` and the Store
|
|
436
|
+
* answers instead.
|
|
437
|
+
*
|
|
438
|
+
* With a per-process cache and several instances, another instance can read Overrides up to `ttl` seconds old. Share
|
|
439
|
+
* a cache, such as Redis, to avoid it.
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* const store = new CachedStore({ store: prismaStore, cache: new MemoryCache(), ttl: 60 });
|
|
443
|
+
*/
|
|
444
|
+
declare class CachedStore implements SettingsStore {
|
|
445
|
+
#private;
|
|
446
|
+
/**
|
|
447
|
+
* @param options - The Store to wrap, the cache and the TTL.
|
|
448
|
+
* @throws `RangeError` when `ttl` isn't a positive number.
|
|
449
|
+
*/
|
|
450
|
+
constructor(options: CachedStoreOptions);
|
|
451
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
452
|
+
read(owner: Owner): Promise<StoredOverrides | null>;
|
|
453
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
454
|
+
write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
|
|
455
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
456
|
+
delete(owner: Owner): Promise<void>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Options of {@link MemoryCache}. */
|
|
460
|
+
interface MemoryCacheOptions {
|
|
461
|
+
/** Most entries kept; the oldest are evicted past it. Defaults to 1000. */
|
|
462
|
+
readonly maxEntries?: number;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* A {@link SettingsCache} in process memory. Entries expire after their TTL and the oldest are evicted past
|
|
466
|
+
* `maxEntries`. It is per process: instances don't share it.
|
|
467
|
+
*
|
|
468
|
+
* @example
|
|
469
|
+
* const cache = new MemoryCache({ maxEntries: 5000 });
|
|
470
|
+
*/
|
|
471
|
+
declare class MemoryCache implements SettingsCache {
|
|
472
|
+
#private;
|
|
473
|
+
/**
|
|
474
|
+
* @param options - The size limit.
|
|
475
|
+
* @throws `RangeError` when `maxEntries` isn't a positive integer.
|
|
476
|
+
*/
|
|
477
|
+
constructor(options?: MemoryCacheOptions);
|
|
478
|
+
/** {@inheritDoc SettingsCache.get} */
|
|
479
|
+
get(key: string): Promise<string | undefined>;
|
|
480
|
+
/** {@inheritDoc SettingsCache.set} */
|
|
481
|
+
set(key: string, value: string, ttlSeconds: number): Promise<void>;
|
|
482
|
+
/** {@inheritDoc SettingsCache.delete} */
|
|
483
|
+
delete(key: string): Promise<void>;
|
|
484
|
+
}
|
|
485
|
+
|
|
317
486
|
/**
|
|
318
487
|
* A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
|
|
319
488
|
* Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
|
|
@@ -358,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
|
|
|
358
527
|
constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
|
|
359
528
|
}
|
|
360
529
|
|
|
361
|
-
export { MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
|
|
530
|
+
export { CachedStore, type CachedStoreOptions, MemoryCache, type MemoryCacheOptions, MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SealedStore, type SealedStoreOptions, type SealingVault, type SettingsCache, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
|
package/dist/index.js
CHANGED
|
@@ -467,4 +467,187 @@ function asJson(overrides) {
|
|
|
467
467
|
return JSON.parse(JSON.stringify(overrides));
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
|
|
470
|
+
// src/sealed-store.ts
|
|
471
|
+
function defaultIsSealed(value) {
|
|
472
|
+
return typeof value === "string" && value.startsWith("vault:");
|
|
473
|
+
}
|
|
474
|
+
function contextOf(owner, path) {
|
|
475
|
+
return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
|
|
476
|
+
}
|
|
477
|
+
var SealedStore = class {
|
|
478
|
+
#store;
|
|
479
|
+
#vault;
|
|
480
|
+
#paths;
|
|
481
|
+
/**
|
|
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.
|
|
484
|
+
*/
|
|
485
|
+
constructor(options) {
|
|
486
|
+
this.#store = options.store;
|
|
487
|
+
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) {
|
|
493
|
+
throw new TypeError(
|
|
494
|
+
`SealedStore: path "${inner}" is inside path "${path}".`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
500
|
+
async read(owner) {
|
|
501
|
+
const stored = await this.#store.read(owner);
|
|
502
|
+
if (stored === null) return null;
|
|
503
|
+
const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
|
|
504
|
+
let overrides = stored.overrides;
|
|
505
|
+
for (const path of this.#paths) {
|
|
506
|
+
const value = getAt(overrides, path);
|
|
507
|
+
if (!isSealed(value)) continue;
|
|
508
|
+
const plaintext = await this.#vault.decryptJson(
|
|
509
|
+
value,
|
|
510
|
+
contextOf(owner, path)
|
|
511
|
+
);
|
|
512
|
+
overrides = setAt(overrides, path, plaintext);
|
|
513
|
+
}
|
|
514
|
+
return { overrides, version: stored.version };
|
|
515
|
+
}
|
|
516
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
517
|
+
async write(owner, overrides, expectedVersion) {
|
|
518
|
+
let sealed = overrides;
|
|
519
|
+
for (const path of this.#paths) {
|
|
520
|
+
if (!hasAt(sealed, path)) continue;
|
|
521
|
+
const value = getAt(sealed, path);
|
|
522
|
+
if (value === void 0) continue;
|
|
523
|
+
sealed = setAt(
|
|
524
|
+
sealed,
|
|
525
|
+
path,
|
|
526
|
+
await this.#vault.encryptJson(value, contextOf(owner, path))
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return this.#store.write(owner, sealed, expectedVersion);
|
|
530
|
+
}
|
|
531
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
532
|
+
async delete(owner) {
|
|
533
|
+
await this.#store.delete(owner);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// src/cached-store.ts
|
|
538
|
+
var CachedStore = class {
|
|
539
|
+
#store;
|
|
540
|
+
#cache;
|
|
541
|
+
#ttl;
|
|
542
|
+
#prefix;
|
|
543
|
+
#onError;
|
|
544
|
+
/**
|
|
545
|
+
* @param options - The Store to wrap, the cache and the TTL.
|
|
546
|
+
* @throws `RangeError` when `ttl` isn't a positive number.
|
|
547
|
+
*/
|
|
548
|
+
constructor(options) {
|
|
549
|
+
if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
|
|
550
|
+
throw new RangeError("CachedStore: ttl must be a positive number.");
|
|
551
|
+
}
|
|
552
|
+
this.#store = options.store;
|
|
553
|
+
this.#cache = options.cache;
|
|
554
|
+
this.#ttl = options.ttl;
|
|
555
|
+
this.#prefix = options.prefix ?? "settings";
|
|
556
|
+
this.#onError = options.onError;
|
|
557
|
+
}
|
|
558
|
+
/** {@inheritDoc SettingsStore.read} */
|
|
559
|
+
async read(owner) {
|
|
560
|
+
const key2 = this.#key(owner);
|
|
561
|
+
try {
|
|
562
|
+
const cached = await this.#cache.get(key2);
|
|
563
|
+
if (cached !== void 0) {
|
|
564
|
+
return JSON.parse(cached);
|
|
565
|
+
}
|
|
566
|
+
} catch (error) {
|
|
567
|
+
this.#report(error);
|
|
568
|
+
}
|
|
569
|
+
const stored = await this.#store.read(owner);
|
|
570
|
+
try {
|
|
571
|
+
await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
this.#report(error);
|
|
574
|
+
}
|
|
575
|
+
return stored;
|
|
576
|
+
}
|
|
577
|
+
/** {@inheritDoc SettingsStore.write} */
|
|
578
|
+
async write(owner, overrides, expectedVersion) {
|
|
579
|
+
try {
|
|
580
|
+
return await this.#store.write(owner, overrides, expectedVersion);
|
|
581
|
+
} finally {
|
|
582
|
+
await this.#evict(owner);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** {@inheritDoc SettingsStore.delete} */
|
|
586
|
+
async delete(owner) {
|
|
587
|
+
try {
|
|
588
|
+
await this.#store.delete(owner);
|
|
589
|
+
} finally {
|
|
590
|
+
await this.#evict(owner);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async #evict(owner) {
|
|
594
|
+
try {
|
|
595
|
+
await this.#cache.delete(this.#key(owner));
|
|
596
|
+
} catch (error) {
|
|
597
|
+
this.#report(error);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
#key(owner) {
|
|
601
|
+
return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
|
|
602
|
+
}
|
|
603
|
+
#report(error) {
|
|
604
|
+
this.#onError?.(error);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// src/memory-cache.ts
|
|
609
|
+
var MemoryCache = class {
|
|
610
|
+
#entries = /* @__PURE__ */ new Map();
|
|
611
|
+
#maxEntries;
|
|
612
|
+
/**
|
|
613
|
+
* @param options - The size limit.
|
|
614
|
+
* @throws `RangeError` when `maxEntries` isn't a positive integer.
|
|
615
|
+
*/
|
|
616
|
+
constructor(options = {}) {
|
|
617
|
+
const { maxEntries = 1e3 } = options;
|
|
618
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
619
|
+
throw new RangeError(
|
|
620
|
+
"MemoryCache: maxEntries must be a positive integer."
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
this.#maxEntries = maxEntries;
|
|
624
|
+
}
|
|
625
|
+
/** {@inheritDoc SettingsCache.get} */
|
|
626
|
+
async get(key2) {
|
|
627
|
+
const entry = this.#entries.get(key2);
|
|
628
|
+
if (entry === void 0) return void 0;
|
|
629
|
+
if (entry.expiresAt <= Date.now()) {
|
|
630
|
+
this.#entries.delete(key2);
|
|
631
|
+
return void 0;
|
|
632
|
+
}
|
|
633
|
+
return entry.value;
|
|
634
|
+
}
|
|
635
|
+
/** {@inheritDoc SettingsCache.set} */
|
|
636
|
+
async set(key2, value, ttlSeconds) {
|
|
637
|
+
this.#entries.delete(key2);
|
|
638
|
+
this.#entries.set(key2, {
|
|
639
|
+
value,
|
|
640
|
+
expiresAt: Date.now() + ttlSeconds * 1e3
|
|
641
|
+
});
|
|
642
|
+
while (this.#entries.size > this.#maxEntries) {
|
|
643
|
+
const oldest = this.#entries.keys().next().value;
|
|
644
|
+
this.#entries.delete(oldest);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** {@inheritDoc SettingsCache.delete} */
|
|
648
|
+
async delete(key2) {
|
|
649
|
+
this.#entries.delete(key2);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
export { CachedStore, MemoryCache, MemoryStore, SealedStore, SettingsConflictError, SettingsDefinitionError, SettingsValidationError, createSettings, defineSettings };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rezwanul7/settings",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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",
|