@rezwanul7/settings 0.1.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 +89 -34
- package/dist/index.cjs +221 -1
- package/dist/index.d.cts +192 -10
- package/dist/index.d.ts +192 -10
- package/dist/index.js +219 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -76,15 +76,18 @@ the schema again breaks:
|
|
|
76
76
|
- Don't use schema-level defaults, such as Zod's `.default()`. The Settings definition's `defaults` are the one place
|
|
77
77
|
defaults live, and every field already has one there.
|
|
78
78
|
- Use only JSON values: strings, finite numbers, booleans, `null`, arrays and plain objects. Every Store keeps the
|
|
79
|
-
Overrides as JSON, so a
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
Overrides as JSON, so a write with anything else, such as a `Date`, a class instance, a `Map`, `NaN` or an array
|
|
80
|
+
hole, throws `SettingsValidationError` with a `"Not a JSON value"` issue at its path, even where the schema accepts
|
|
81
|
+
it. For a date, store the string and convert it where the setting is used.
|
|
82
82
|
|
|
83
83
|
## Creating the settings
|
|
84
84
|
|
|
85
|
-
Pass every Settings definition, by Scope name, and a Store to `createSettings`. Then `scope(name, id)`
|
|
86
|
-
Owner's `ScopedSettings
|
|
87
|
-
both rules are checked by the types.
|
|
85
|
+
Pass every Settings definition, by Scope name, and a Store to `createSettings`. Then `scope(name, id)` returns one
|
|
86
|
+
Owner's settings, a `ScopedSettings`: `scope("user", "42")` is user `42`, not every user. Owner ids are strings. A
|
|
87
|
+
single-owner Scope takes no id; any other Scope requires one, and both rules are checked by the types.
|
|
88
|
+
|
|
89
|
+
The Scope name is stored with every Owner's Overrides. Renaming a Scope, such as `user` to `member`, leaves the stored
|
|
90
|
+
rows under the old name, so its Owners read the defaults until the rows are migrated to the new name.
|
|
88
91
|
|
|
89
92
|
```ts
|
|
90
93
|
import { createSettings, MemoryStore } from "@rezwanul7/settings";
|
|
@@ -140,7 +143,7 @@ await me.reset(); // deletes everything stored for the Owner
|
|
|
140
143
|
| `get(path)` | One typed value of the Effective settings, at a dot path such as `"notifications.email"`. |
|
|
141
144
|
| `set(path, value)` | Stores `value` as the Override at `path`, keeping the other Overrides. |
|
|
142
145
|
| `update(partial)` | Deep-merges `partial` into the Overrides, to change several settings at once. |
|
|
143
|
-
| `getOverrides()` | The
|
|
146
|
+
| `getOverrides()` | The Owner's own Overrides, without the defaults, or `{}` when nothing is stored. |
|
|
144
147
|
| `reset(path)` | Removes the Override at `path` (and every Override under it), so the default applies again. |
|
|
145
148
|
| `reset()` | Deletes everything stored for the Owner, so it reads the defaults. |
|
|
146
149
|
|
|
@@ -151,15 +154,16 @@ await me.reset(); // deletes everything stored for the Owner
|
|
|
151
154
|
issue, and `get(path)` reads one as `undefined`. Stored ones are ignored as unknown keys.
|
|
152
155
|
- Merging works the same on reads and in `update`: plain objects merge key by key; arrays, scalars and `null` replace.
|
|
153
156
|
- In `update`, an `undefined` value leaves that Override as it was, and `null` is stored as a value, which passes only
|
|
154
|
-
if the schema allows it.
|
|
157
|
+
if the schema allows it. `set(path, undefined)` throws `SettingsValidationError`; use `reset(path)` to remove an
|
|
158
|
+
Override.
|
|
155
159
|
- A value equal to the current default is still stored as an Override, so a later change of the default doesn't change
|
|
156
160
|
what the Owner chose. `reset(path)` is how an Owner follows the default again; with no Override at `path`, it
|
|
157
161
|
writes nothing.
|
|
158
162
|
- A write whose Effective settings fail the schema throws `SettingsValidationError` and stores nothing. So does a
|
|
159
163
|
write with a key the schema doesn't know, which a schema that strips unknown keys would otherwise drop silently; the
|
|
160
164
|
issue for it has the message `"Unknown setting"`, as on read.
|
|
161
|
-
- `getOverrides()` suits a settings page that marks which values are the Owner's own
|
|
162
|
-
|
|
165
|
+
- `getOverrides()` suits a settings page that marks which values are the Owner's own. It leaves out the Overrides a
|
|
166
|
+
read would ignore, the same as `get()`, so the two always agree, and it is typed like the partial `update` takes.
|
|
163
167
|
|
|
164
168
|
### Authorize the Owner first
|
|
165
169
|
|
|
@@ -279,6 +283,8 @@ the Owner's page ([ADR-0001](./docs/adr/0001-overrides-only-ignored-when-invalid
|
|
|
279
283
|
to ignore. The rest of the Owner's Overrides still apply.
|
|
280
284
|
- `onInvalid(owner, issues)` is called with the issues naming what was ignored; an unknown key is reported as an issue
|
|
281
285
|
with the message `"Unknown setting"`. Without `onInvalid`, they are ignored silently, so pass it to log them.
|
|
286
|
+
- `getOverrides()` ignores them the same way, but returns the Overrides it kept instead of throwing when the
|
|
287
|
+
Effective settings still fail, so a page can show and fix them.
|
|
282
288
|
- A read never changes the stored document. The next successful write for that Owner drops the ignored Overrides.
|
|
283
289
|
- Writes read the Overrides first, so `onInvalid` can also be called during `set`, `update` or `reset(path)`, and more
|
|
284
290
|
than once for one write when it retries after a conflict.
|
|
@@ -301,15 +307,17 @@ again later. `reset()` deletes without a version check. `MemoryStore` keeps an O
|
|
|
301
307
|
|
|
302
308
|
## Secret settings
|
|
303
309
|
|
|
304
|
-
|
|
305
|
-
[`@rezwanul7/secret-vault`](../secret-vault/README.md)
|
|
306
|
-
|
|
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).
|
|
307
315
|
|
|
308
316
|
```ts
|
|
309
317
|
import {
|
|
310
318
|
createSettings,
|
|
311
319
|
defineSettings,
|
|
312
|
-
|
|
320
|
+
SealedStore,
|
|
313
321
|
} from "@rezwanul7/settings";
|
|
314
322
|
import { PrismaStore } from "@rezwanul7/settings/prisma";
|
|
315
323
|
import { z } from "zod";
|
|
@@ -320,8 +328,7 @@ const organization = defineSettings({
|
|
|
320
328
|
schema: z.object({
|
|
321
329
|
smtp: z.object({
|
|
322
330
|
host: z.string(),
|
|
323
|
-
|
|
324
|
-
password: z.string().startsWith("vault:").nullable(),
|
|
331
|
+
password: z.string().min(1).nullable(),
|
|
325
332
|
}),
|
|
326
333
|
}),
|
|
327
334
|
defaults: { smtp: { host: "localhost", password: null } },
|
|
@@ -329,30 +336,74 @@ const organization = defineSettings({
|
|
|
329
336
|
|
|
330
337
|
const settings = createSettings({
|
|
331
338
|
definitions: { organization },
|
|
332
|
-
store: new
|
|
339
|
+
store: new SealedStore({
|
|
340
|
+
store: new PrismaStore({ delegate: prisma.setting }),
|
|
341
|
+
vault,
|
|
342
|
+
paths: ["smtp.password"],
|
|
343
|
+
}),
|
|
333
344
|
});
|
|
334
345
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
```
|
|
338
351
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.
|
|
344
372
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
:
|
|
351
|
-
}
|
|
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
|
+
});
|
|
352
385
|
```
|
|
353
386
|
|
|
354
|
-
|
|
355
|
-
|
|
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
|
+
```
|
|
356
407
|
|
|
357
408
|
## Errors
|
|
358
409
|
|
|
@@ -380,6 +431,10 @@ try {
|
|
|
380
431
|
}
|
|
381
432
|
```
|
|
382
433
|
|
|
434
|
+
Each error sets `name` to its class name. When the app may load both the ESM and the CommonJS build of the package,
|
|
435
|
+
as mixed bundling can, `instanceof` fails across the two copies; check `error.name === "SettingsValidationError"`
|
|
436
|
+
there instead.
|
|
437
|
+
|
|
383
438
|
## API reference
|
|
384
439
|
|
|
385
440
|
Every export, parameter, and default is listed in the [API reference](./docs/api/README.md), generated from the source.
|
package/dist/index.cjs
CHANGED
|
@@ -140,6 +140,25 @@ function forbiddenKeysIn(value, path) {
|
|
|
140
140
|
([key2, child]) => FORBIDDEN_KEYS.has(key2) ? [{ message: "Forbidden key", path: [...path, key2] }] : forbiddenKeysIn(child, [...path, key2])
|
|
141
141
|
);
|
|
142
142
|
}
|
|
143
|
+
function nonJsonValues(path, value) {
|
|
144
|
+
return nonJsonValuesIn(value, path === void 0 ? [] : keysOf(path));
|
|
145
|
+
}
|
|
146
|
+
function nonJsonValuesIn(value, path) {
|
|
147
|
+
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(value)) {
|
|
151
|
+
return Array.from(value).flatMap(
|
|
152
|
+
(item, index) => nonJsonValuesIn(item, [...path, index])
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (isPlainObject(value) && Object.getOwnPropertySymbols(value).length === 0) {
|
|
156
|
+
return Object.entries(value).flatMap(
|
|
157
|
+
([key2, child]) => child === void 0 ? [] : nonJsonValuesIn(child, [...path, key2])
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return [{ message: "Not a JSON value", path: [...path] }];
|
|
161
|
+
}
|
|
143
162
|
function getAt(value, path) {
|
|
144
163
|
let current = value;
|
|
145
164
|
for (const key2 of keysOf(path)) {
|
|
@@ -349,16 +368,27 @@ function createSettings(options) {
|
|
|
349
368
|
},
|
|
350
369
|
async set(path, value) {
|
|
351
370
|
rejectForbiddenKeys(path, value);
|
|
371
|
+
if (value === void 0) {
|
|
372
|
+
throw new SettingsValidationError([
|
|
373
|
+
{
|
|
374
|
+
message: "Not a JSON value: use reset(path) to remove an Override",
|
|
375
|
+
path: path.split(".")
|
|
376
|
+
}
|
|
377
|
+
]);
|
|
378
|
+
}
|
|
379
|
+
rejectNonJsonValues(path, value);
|
|
352
380
|
await change((overrides) => setAt(overrides, path, value));
|
|
353
381
|
},
|
|
354
382
|
async update(partial) {
|
|
355
383
|
rejectForbiddenKeys(void 0, partial);
|
|
384
|
+
rejectNonJsonValues(void 0, partial);
|
|
356
385
|
await change(
|
|
357
386
|
(overrides) => mergeOverrides(overrides, withoutUndefined(partial))
|
|
358
387
|
);
|
|
359
388
|
},
|
|
360
389
|
async getOverrides() {
|
|
361
|
-
|
|
390
|
+
const stored = await readOverrides();
|
|
391
|
+
return (await withoutStale(stored?.overrides ?? {})).overrides;
|
|
362
392
|
},
|
|
363
393
|
async reset(path) {
|
|
364
394
|
if (path === void 0) {
|
|
@@ -393,6 +423,10 @@ function rejectForbiddenKeys(path, value) {
|
|
|
393
423
|
const issues = forbiddenKeys(path, value);
|
|
394
424
|
if (issues.length > 0) throw new SettingsValidationError(issues);
|
|
395
425
|
}
|
|
426
|
+
function rejectNonJsonValues(path, value) {
|
|
427
|
+
const issues = nonJsonValues(path, value);
|
|
428
|
+
if (issues.length > 0) throw new SettingsValidationError(issues);
|
|
429
|
+
}
|
|
396
430
|
function unknownSettings(overrides, output) {
|
|
397
431
|
return unknownKeys(overrides, output).map((path) => ({
|
|
398
432
|
message: "Unknown setting",
|
|
@@ -435,7 +469,193 @@ function asJson(overrides) {
|
|
|
435
469
|
return JSON.parse(JSON.stringify(overrides));
|
|
436
470
|
}
|
|
437
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;
|
|
438
657
|
exports.MemoryStore = MemoryStore;
|
|
658
|
+
exports.SealedStore = SealedStore;
|
|
439
659
|
exports.SettingsConflictError = SettingsConflictError;
|
|
440
660
|
exports.SettingsDefinitionError = SettingsDefinitionError;
|
|
441
661
|
exports.SettingsValidationError = SettingsValidationError;
|
package/dist/index.d.cts
CHANGED
|
@@ -180,6 +180,10 @@ interface SettingsOptions<Definitions extends Record<string, SettingsDefinition>
|
|
|
180
180
|
* The keys `__proto__`, `constructor` and `prototype` can't be setting names: a write with one in its path or value
|
|
181
181
|
* throws {@link SettingsValidationError} with a `"Forbidden key"` issue, and `get(path)` reads one as `undefined`.
|
|
182
182
|
*
|
|
183
|
+
* Overrides are kept as JSON, so a write with a value that isn't JSON, such as a `Date`, a class instance, `NaN` or an
|
|
184
|
+
* array hole, throws {@link SettingsValidationError} with a `"Not a JSON value"` issue at its path, even where the
|
|
185
|
+
* schema accepts it.
|
|
186
|
+
*
|
|
183
187
|
* @typeParam T - The Scope's settings type.
|
|
184
188
|
*/
|
|
185
189
|
interface ScopedSettings<T> {
|
|
@@ -208,9 +212,11 @@ interface ScopedSettings<T> {
|
|
|
208
212
|
* still stored, so a later change of the default doesn't change what the Owner chose.
|
|
209
213
|
*
|
|
210
214
|
* @param path - A dot path into the settings, such as `"notifications.email"`.
|
|
211
|
-
* @param value - The value to store at `path`.
|
|
212
|
-
*
|
|
213
|
-
*
|
|
215
|
+
* @param value - The value to store at `path`. Use {@link ScopedSettings.reset} rather than `undefined` to remove an
|
|
216
|
+
* Override.
|
|
217
|
+
* @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, the change has
|
|
218
|
+
* a key the schema doesn't know, reported as an `"Unknown setting"` issue, or `value` is `undefined` or isn't JSON;
|
|
219
|
+
* nothing is stored.
|
|
214
220
|
* @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
|
|
215
221
|
* retries; nothing is stored.
|
|
216
222
|
*/
|
|
@@ -221,19 +227,23 @@ interface ScopedSettings<T> {
|
|
|
221
227
|
* stored as a value and passes only if the schema allows it.
|
|
222
228
|
*
|
|
223
229
|
* @param partial - The settings to change, at any depth, such as `{ theme: "dark", notifications: { email: false } }`.
|
|
224
|
-
* @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema,
|
|
225
|
-
* a key the schema doesn't know, reported as an `"Unknown setting"` issue; nothing is
|
|
230
|
+
* @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, `partial` has
|
|
231
|
+
* a key the schema doesn't know, reported as an `"Unknown setting"` issue, or a value in it isn't JSON; nothing is
|
|
232
|
+
* stored.
|
|
226
233
|
* @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
|
|
227
234
|
* retries; nothing is stored.
|
|
228
235
|
*/
|
|
229
236
|
update(partial: DeepPartial<T>): Promise<void>;
|
|
230
237
|
/**
|
|
231
|
-
* Reads the Owner's Overrides
|
|
232
|
-
*
|
|
238
|
+
* Reads the Owner's Overrides without the defaults, for example to show which values are the Owner's own choices.
|
|
239
|
+
* Overrides a read would ignore are left out and reported to `onInvalid`, the same as {@link ScopedSettings.get},
|
|
240
|
+
* so the two always agree; the stored document isn't changed. Unlike `get`, it doesn't throw when the Effective
|
|
241
|
+
* settings still fail after leaving those out, so a page can show the Overrides that remain and fix them.
|
|
233
242
|
*
|
|
234
|
-
* @returns The
|
|
243
|
+
* @returns The Overrides a read uses, or `{}` when nothing is stored.
|
|
244
|
+
* @throws {@link SettingsDefinitionError} If the Scope's defaults fail its schema.
|
|
235
245
|
*/
|
|
236
|
-
getOverrides(): Promise<
|
|
246
|
+
getOverrides(): Promise<DeepPartial<T>>;
|
|
237
247
|
/**
|
|
238
248
|
* Removes the Override at `path`, so that setting follows the default again. Resetting an object removes every
|
|
239
249
|
* Override under it. With no Override at or under `path`, it writes nothing.
|
|
@@ -257,6 +267,9 @@ type OwnerIdArgs<D> = D extends SettingsDefinition<unknown, true> ? [] : [id: st
|
|
|
257
267
|
* Creates the settings for an app's Scopes over one Store. Call `scope(name, id)` on the result to read and write
|
|
258
268
|
* one Owner's settings; a single-owner Scope takes no id.
|
|
259
269
|
*
|
|
270
|
+
* The Scope name is stored with every Owner's Overrides, so renaming a Scope needs a migration of its stored rows;
|
|
271
|
+
* until then its Owners read the defaults.
|
|
272
|
+
*
|
|
260
273
|
* Each Scope's defaults are validated against its schema on the first operation on that Scope, and the outcome is
|
|
261
274
|
* kept for the life of the returned object. If the validation itself throws, such as an async validator that can't
|
|
262
275
|
* reach its service, that operation rejects with the error and the next one checks again.
|
|
@@ -301,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
|
|
|
301
314
|
delete(owner: Owner): Promise<void>;
|
|
302
315
|
}
|
|
303
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
|
+
|
|
304
486
|
/**
|
|
305
487
|
* A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
|
|
306
488
|
* Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
|
|
@@ -345,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
|
|
|
345
527
|
constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
|
|
346
528
|
}
|
|
347
529
|
|
|
348
|
-
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
|
@@ -180,6 +180,10 @@ interface SettingsOptions<Definitions extends Record<string, SettingsDefinition>
|
|
|
180
180
|
* The keys `__proto__`, `constructor` and `prototype` can't be setting names: a write with one in its path or value
|
|
181
181
|
* throws {@link SettingsValidationError} with a `"Forbidden key"` issue, and `get(path)` reads one as `undefined`.
|
|
182
182
|
*
|
|
183
|
+
* Overrides are kept as JSON, so a write with a value that isn't JSON, such as a `Date`, a class instance, `NaN` or an
|
|
184
|
+
* array hole, throws {@link SettingsValidationError} with a `"Not a JSON value"` issue at its path, even where the
|
|
185
|
+
* schema accepts it.
|
|
186
|
+
*
|
|
183
187
|
* @typeParam T - The Scope's settings type.
|
|
184
188
|
*/
|
|
185
189
|
interface ScopedSettings<T> {
|
|
@@ -208,9 +212,11 @@ interface ScopedSettings<T> {
|
|
|
208
212
|
* still stored, so a later change of the default doesn't change what the Owner chose.
|
|
209
213
|
*
|
|
210
214
|
* @param path - A dot path into the settings, such as `"notifications.email"`.
|
|
211
|
-
* @param value - The value to store at `path`.
|
|
212
|
-
*
|
|
213
|
-
*
|
|
215
|
+
* @param value - The value to store at `path`. Use {@link ScopedSettings.reset} rather than `undefined` to remove an
|
|
216
|
+
* Override.
|
|
217
|
+
* @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, the change has
|
|
218
|
+
* a key the schema doesn't know, reported as an `"Unknown setting"` issue, or `value` is `undefined` or isn't JSON;
|
|
219
|
+
* nothing is stored.
|
|
214
220
|
* @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
|
|
215
221
|
* retries; nothing is stored.
|
|
216
222
|
*/
|
|
@@ -221,19 +227,23 @@ interface ScopedSettings<T> {
|
|
|
221
227
|
* stored as a value and passes only if the schema allows it.
|
|
222
228
|
*
|
|
223
229
|
* @param partial - The settings to change, at any depth, such as `{ theme: "dark", notifications: { email: false } }`.
|
|
224
|
-
* @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema,
|
|
225
|
-
* a key the schema doesn't know, reported as an `"Unknown setting"` issue; nothing is
|
|
230
|
+
* @throws {@link SettingsValidationError} If the Effective settings with the change fail the schema, `partial` has
|
|
231
|
+
* a key the schema doesn't know, reported as an `"Unknown setting"` issue, or a value in it isn't JSON; nothing is
|
|
232
|
+
* stored.
|
|
226
233
|
* @throws {@link SettingsConflictError} If other writes to the Owner kept winning the version race for `maxRetries`
|
|
227
234
|
* retries; nothing is stored.
|
|
228
235
|
*/
|
|
229
236
|
update(partial: DeepPartial<T>): Promise<void>;
|
|
230
237
|
/**
|
|
231
|
-
* Reads the Owner's Overrides
|
|
232
|
-
*
|
|
238
|
+
* Reads the Owner's Overrides without the defaults, for example to show which values are the Owner's own choices.
|
|
239
|
+
* Overrides a read would ignore are left out and reported to `onInvalid`, the same as {@link ScopedSettings.get},
|
|
240
|
+
* so the two always agree; the stored document isn't changed. Unlike `get`, it doesn't throw when the Effective
|
|
241
|
+
* settings still fail after leaving those out, so a page can show the Overrides that remain and fix them.
|
|
233
242
|
*
|
|
234
|
-
* @returns The
|
|
243
|
+
* @returns The Overrides a read uses, or `{}` when nothing is stored.
|
|
244
|
+
* @throws {@link SettingsDefinitionError} If the Scope's defaults fail its schema.
|
|
235
245
|
*/
|
|
236
|
-
getOverrides(): Promise<
|
|
246
|
+
getOverrides(): Promise<DeepPartial<T>>;
|
|
237
247
|
/**
|
|
238
248
|
* Removes the Override at `path`, so that setting follows the default again. Resetting an object removes every
|
|
239
249
|
* Override under it. With no Override at or under `path`, it writes nothing.
|
|
@@ -257,6 +267,9 @@ type OwnerIdArgs<D> = D extends SettingsDefinition<unknown, true> ? [] : [id: st
|
|
|
257
267
|
* Creates the settings for an app's Scopes over one Store. Call `scope(name, id)` on the result to read and write
|
|
258
268
|
* one Owner's settings; a single-owner Scope takes no id.
|
|
259
269
|
*
|
|
270
|
+
* The Scope name is stored with every Owner's Overrides, so renaming a Scope needs a migration of its stored rows;
|
|
271
|
+
* until then its Owners read the defaults.
|
|
272
|
+
*
|
|
260
273
|
* Each Scope's defaults are validated against its schema on the first operation on that Scope, and the outcome is
|
|
261
274
|
* kept for the life of the returned object. If the validation itself throws, such as an async validator that can't
|
|
262
275
|
* reach its service, that operation rejects with the error and the next one checks again.
|
|
@@ -301,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
|
|
|
301
314
|
delete(owner: Owner): Promise<void>;
|
|
302
315
|
}
|
|
303
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
|
+
|
|
304
486
|
/**
|
|
305
487
|
* A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
|
|
306
488
|
* Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
|
|
@@ -345,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
|
|
|
345
527
|
constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
|
|
346
528
|
}
|
|
347
529
|
|
|
348
|
-
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
|
@@ -138,6 +138,25 @@ function forbiddenKeysIn(value, path) {
|
|
|
138
138
|
([key2, child]) => FORBIDDEN_KEYS.has(key2) ? [{ message: "Forbidden key", path: [...path, key2] }] : forbiddenKeysIn(child, [...path, key2])
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
|
+
function nonJsonValues(path, value) {
|
|
142
|
+
return nonJsonValuesIn(value, path === void 0 ? [] : keysOf(path));
|
|
143
|
+
}
|
|
144
|
+
function nonJsonValuesIn(value, path) {
|
|
145
|
+
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
return Array.from(value).flatMap(
|
|
150
|
+
(item, index) => nonJsonValuesIn(item, [...path, index])
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (isPlainObject(value) && Object.getOwnPropertySymbols(value).length === 0) {
|
|
154
|
+
return Object.entries(value).flatMap(
|
|
155
|
+
([key2, child]) => child === void 0 ? [] : nonJsonValuesIn(child, [...path, key2])
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return [{ message: "Not a JSON value", path: [...path] }];
|
|
159
|
+
}
|
|
141
160
|
function getAt(value, path) {
|
|
142
161
|
let current = value;
|
|
143
162
|
for (const key2 of keysOf(path)) {
|
|
@@ -347,16 +366,27 @@ function createSettings(options) {
|
|
|
347
366
|
},
|
|
348
367
|
async set(path, value) {
|
|
349
368
|
rejectForbiddenKeys(path, value);
|
|
369
|
+
if (value === void 0) {
|
|
370
|
+
throw new SettingsValidationError([
|
|
371
|
+
{
|
|
372
|
+
message: "Not a JSON value: use reset(path) to remove an Override",
|
|
373
|
+
path: path.split(".")
|
|
374
|
+
}
|
|
375
|
+
]);
|
|
376
|
+
}
|
|
377
|
+
rejectNonJsonValues(path, value);
|
|
350
378
|
await change((overrides) => setAt(overrides, path, value));
|
|
351
379
|
},
|
|
352
380
|
async update(partial) {
|
|
353
381
|
rejectForbiddenKeys(void 0, partial);
|
|
382
|
+
rejectNonJsonValues(void 0, partial);
|
|
354
383
|
await change(
|
|
355
384
|
(overrides) => mergeOverrides(overrides, withoutUndefined(partial))
|
|
356
385
|
);
|
|
357
386
|
},
|
|
358
387
|
async getOverrides() {
|
|
359
|
-
|
|
388
|
+
const stored = await readOverrides();
|
|
389
|
+
return (await withoutStale(stored?.overrides ?? {})).overrides;
|
|
360
390
|
},
|
|
361
391
|
async reset(path) {
|
|
362
392
|
if (path === void 0) {
|
|
@@ -391,6 +421,10 @@ function rejectForbiddenKeys(path, value) {
|
|
|
391
421
|
const issues = forbiddenKeys(path, value);
|
|
392
422
|
if (issues.length > 0) throw new SettingsValidationError(issues);
|
|
393
423
|
}
|
|
424
|
+
function rejectNonJsonValues(path, value) {
|
|
425
|
+
const issues = nonJsonValues(path, value);
|
|
426
|
+
if (issues.length > 0) throw new SettingsValidationError(issues);
|
|
427
|
+
}
|
|
394
428
|
function unknownSettings(overrides, output) {
|
|
395
429
|
return unknownKeys(overrides, output).map((path) => ({
|
|
396
430
|
message: "Unknown setting",
|
|
@@ -433,4 +467,187 @@ function asJson(overrides) {
|
|
|
433
467
|
return JSON.parse(JSON.stringify(overrides));
|
|
434
468
|
}
|
|
435
469
|
|
|
436
|
-
|
|
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": "
|
|
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",
|