@rezwanul7/settings 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -48
- package/dist/index.cjs +165 -20
- package/dist/index.d.cts +124 -29
- package/dist/index.d.ts +124 -29
- package/dist/index.js +165 -20
- package/dist/prisma.cjs +4 -1
- package/dist/prisma.d.cts +10 -10
- package/dist/prisma.d.ts +10 -10
- package/dist/prisma.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ A single-owner Scope, such as `global` for app-wide settings, has exactly one Ow
|
|
|
38
38
|
import { defineSettings } from "@rezwanul7/settings";
|
|
39
39
|
import { z } from "zod";
|
|
40
40
|
|
|
41
|
-
export const
|
|
41
|
+
export const userDefinition = defineSettings({
|
|
42
42
|
schema: z.object({
|
|
43
43
|
theme: z.enum(["light", "dark", "system"]),
|
|
44
44
|
language: z.string(),
|
|
@@ -51,7 +51,7 @@ export const user = defineSettings({
|
|
|
51
51
|
},
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
export const
|
|
54
|
+
export const globalDefinition = defineSettings({
|
|
55
55
|
schema: z.object({ maintenanceBanner: z.string().nullable() }),
|
|
56
56
|
defaults: { maintenanceBanner: null },
|
|
57
57
|
singleOwner: true,
|
|
@@ -76,33 +76,37 @@ 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
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
Overrides as JSON. This is checked at compile time: `defineSettings` rejects a schema whose output has a `Date`, a
|
|
80
|
+
`Map`, a `Set`, a `bigint` or a class instance, such as `z.coerce.date()`, with an error at `schema` that names the
|
|
81
|
+
path. For a date, store the string and convert it where the setting is used. Where the output type is `unknown` or
|
|
82
|
+
`any`, or the caller is untyped, a write with a value that isn't JSON, such as a `Date`, `NaN` or an array hole,
|
|
83
|
+
throws `SettingsValidationError` with a `"Not a JSON value"` issue at its path, even where the schema accepts it.
|
|
82
84
|
|
|
83
85
|
## Creating the settings
|
|
84
86
|
|
|
85
87
|
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
|
|
87
|
-
|
|
88
|
+
Owner's settings, a `ScopedSettings`: `scope("user", "42")` is user `42`, not every user. Owner ids are non-empty
|
|
89
|
+
strings: `scope("user", "")` throws a `TypeError`, so callers whose session value is missing can't all share one
|
|
90
|
+
Owner. There are no other format rules, so `"42"` and `" 42"` are different Owners. A single-owner Scope takes no id;
|
|
91
|
+
any other Scope requires one, and both rules are checked by the types.
|
|
88
92
|
|
|
89
93
|
The Scope name is stored with every Owner's Overrides. Renaming a Scope, such as `user` to `member`, leaves the stored
|
|
90
94
|
rows under the old name, so its Owners read the defaults until the rows are migrated to the new name.
|
|
91
95
|
|
|
92
96
|
```ts
|
|
93
97
|
import { createSettings, MemoryStore } from "@rezwanul7/settings";
|
|
94
|
-
import {
|
|
98
|
+
import { globalDefinition, userDefinition } from "./settings-definitions";
|
|
95
99
|
|
|
96
100
|
const settings = createSettings({
|
|
97
|
-
definitions: { user, global },
|
|
101
|
+
definitions: { user: userDefinition, global: globalDefinition },
|
|
98
102
|
store: new MemoryStore(),
|
|
99
103
|
onInvalid: (owner, issues) => {
|
|
100
|
-
console.warn("Ignored stale
|
|
104
|
+
console.warn("Ignored stale Overrides", owner, issues);
|
|
101
105
|
},
|
|
102
106
|
maxRetries: 3,
|
|
103
107
|
});
|
|
104
108
|
|
|
105
|
-
const
|
|
109
|
+
const user42Settings = settings.scope("user", "42");
|
|
106
110
|
const globalSettings = settings.scope("global");
|
|
107
111
|
```
|
|
108
112
|
|
|
@@ -123,17 +127,17 @@ aren't shared between processes. For a database, use the [Prisma Store](#storing
|
|
|
123
127
|
## Reading and writing an Owner's settings
|
|
124
128
|
|
|
125
129
|
```ts
|
|
126
|
-
const
|
|
130
|
+
const user42Settings = settings.scope("user", "42");
|
|
127
131
|
|
|
128
|
-
await
|
|
129
|
-
await
|
|
132
|
+
await user42Settings.get(); // { theme: "system", language: "en", notifications: { email: true, push: false } }
|
|
133
|
+
await user42Settings.get("notifications.email"); // true
|
|
130
134
|
|
|
131
|
-
await
|
|
132
|
-
await
|
|
133
|
-
await
|
|
135
|
+
await user42Settings.set("theme", "dark");
|
|
136
|
+
await user42Settings.update({ language: "de", notifications: { push: true } });
|
|
137
|
+
await user42Settings.getOverrides(); // { theme: "dark", language: "de", notifications: { push: true } }
|
|
134
138
|
|
|
135
|
-
await
|
|
136
|
-
await
|
|
139
|
+
await user42Settings.reset("theme"); // theme follows the default again
|
|
140
|
+
await user42Settings.reset(); // deletes everything stored for the Owner
|
|
137
141
|
```
|
|
138
142
|
|
|
139
143
|
| Member | What it does |
|
|
@@ -165,6 +169,22 @@ await me.reset(); // deletes everything stored for the Owner
|
|
|
165
169
|
- `getOverrides()` suits a settings page that marks which values are the Owner's own. It leaves out the Overrides a
|
|
166
170
|
read would ignore, the same as `get()`, so the two always agree, and it is typed like the partial `update` takes.
|
|
167
171
|
|
|
172
|
+
### Record keys with a dot
|
|
173
|
+
|
|
174
|
+
A record such as `labels: z.record(z.string(), z.number())` may have keys with a dot, such as `"example.com"`, but a
|
|
175
|
+
dot path can't reach them: each dot in a path goes one level deeper. Change such a key through the whole record
|
|
176
|
+
instead:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
await siteSettings.update({ labels: { "example.com": 1, other: 2 } });
|
|
180
|
+
await siteSettings.get("labels"); // { "example.com": 1, other: 2 }
|
|
181
|
+
await siteSettings.reset("labels.example.com"); // nothing is at labels → example → com, so this does nothing
|
|
182
|
+
|
|
183
|
+
const { "example.com": _, ...rest } = await siteSettings.get("labels");
|
|
184
|
+
await siteSettings.set("labels", rest); // replaces the record whole: { other: 2 }
|
|
185
|
+
await siteSettings.reset("labels"); // removes the record's Override, so the default applies again
|
|
186
|
+
```
|
|
187
|
+
|
|
168
188
|
### Authorize the Owner first
|
|
169
189
|
|
|
170
190
|
An Owner id is data, not a credential: the package stores and returns whatever Owner the app names, and checks
|
|
@@ -201,20 +221,20 @@ Each row holds one Owner's Overrides; an Owner with nothing stored has no row an
|
|
|
201
221
|
names, the compound id on `scope` and `ownerId`, and the `version` default of `1`; the model and table names can be
|
|
202
222
|
changed, as long as you pass the matching delegate.
|
|
203
223
|
|
|
204
|
-
The row is removed by `reset()`, and
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
deleting the Owner.
|
|
224
|
+
The row is removed by `reset()`, and the model keeps no version once the row is gone. A row created after it starts
|
|
225
|
+
at a random version rather than at 1, so a write that read the Owner just before a `reset()` is very unlikely to match
|
|
226
|
+
the new row's version and is retried as a conflict. It can't be ruled out: if the versions do match, that write
|
|
227
|
+
overwrites the new row and the Overrides written since the reset are lost. Call `reset()` when no other write to the
|
|
228
|
+
Owner is expected, such as when deleting the Owner.
|
|
209
229
|
|
|
210
230
|
```ts
|
|
211
231
|
import { createSettings } from "@rezwanul7/settings";
|
|
212
232
|
import { PrismaStore } from "@rezwanul7/settings/prisma";
|
|
213
233
|
import { prisma } from "./db";
|
|
214
|
-
import {
|
|
234
|
+
import { globalDefinition, userDefinition } from "./settings-definitions";
|
|
215
235
|
|
|
216
236
|
export const settings = createSettings({
|
|
217
|
-
definitions: { user, global },
|
|
237
|
+
definitions: { user: userDefinition, global: globalDefinition },
|
|
218
238
|
store: new PrismaStore({ delegate: prisma.setting }),
|
|
219
239
|
});
|
|
220
240
|
```
|
|
@@ -233,13 +253,13 @@ import { createSettings } from "@rezwanul7/settings";
|
|
|
233
253
|
import { PrismaStore } from "@rezwanul7/settings/prisma";
|
|
234
254
|
import { auth } from "./auth";
|
|
235
255
|
import { prisma } from "./db";
|
|
236
|
-
import {
|
|
256
|
+
import { globalDefinition, userDefinition } from "./settings-definitions";
|
|
237
257
|
|
|
238
258
|
export const settings = createSettings({
|
|
239
|
-
definitions: { user, global },
|
|
259
|
+
definitions: { user: userDefinition, global: globalDefinition },
|
|
240
260
|
store: new PrismaStore({ delegate: prisma.setting }),
|
|
241
261
|
onInvalid: (owner, issues) => {
|
|
242
|
-
console.warn("Ignored stale
|
|
262
|
+
console.warn("Ignored stale Overrides", { owner, issues });
|
|
243
263
|
},
|
|
244
264
|
});
|
|
245
265
|
|
|
@@ -283,6 +303,8 @@ the Owner's page ([ADR-0001](./docs/adr/0001-overrides-only-ignored-when-invalid
|
|
|
283
303
|
to ignore. The rest of the Owner's Overrides still apply.
|
|
284
304
|
- `onInvalid(owner, issues)` is called with the issues naming what was ignored; an unknown key is reported as an issue
|
|
285
305
|
with the message `"Unknown setting"`. Without `onInvalid`, they are ignored silently, so pass it to log them.
|
|
306
|
+
`onInvalid` isn't awaited, and a hook that throws or returns a rejected promise doesn't change the read or write: the
|
|
307
|
+
failure goes to `console.warn` once for each `createSettings` result.
|
|
286
308
|
- `getOverrides()` ignores them the same way, but returns the Overrides it kept instead of throwing when the
|
|
287
309
|
Effective settings still fail, so a page can show and fix them.
|
|
288
310
|
- A read never changes the stored document. The next successful write for that Owner drops the ignored Overrides.
|
|
@@ -302,14 +324,16 @@ is unchanged. If another write got there first, it reads again and applies its c
|
|
|
302
324
|
requests changing different settings of one Owner both take effect. After `maxRetries` retries that all lost the race,
|
|
303
325
|
it throws `SettingsConflictError`, whose `owner` names the Owner, and stores nothing; it is safe to try the whole write
|
|
304
326
|
again later. `reset()` deletes without a version check. `MemoryStore` keeps an Owner's versions increasing across
|
|
305
|
-
`reset()`, so a write that read the Owner before the reset is a conflict; `PrismaStore`
|
|
306
|
-
[Storing settings with Prisma](#storing-settings-with-prisma).
|
|
327
|
+
`reset()`, so a write that read the Owner before the reset is a conflict; with `PrismaStore` it is only very likely to
|
|
328
|
+
be one, see [Storing settings with Prisma](#storing-settings-with-prisma).
|
|
307
329
|
|
|
308
|
-
##
|
|
330
|
+
## Sealed settings
|
|
309
331
|
|
|
310
|
-
Keep a secret
|
|
311
|
-
[`@rezwanul7/secret-vault`](../secret-vault/README.md) `Vault`.
|
|
312
|
-
|
|
332
|
+
Keep a setting that holds a secret, such as an SMTP password, sealed by wrapping your Store in `SealedStore` with a
|
|
333
|
+
[`@rezwanul7/secret-vault`](../secret-vault/README.md) `Vault`. Pass it the same `definitions` as `createSettings` and
|
|
334
|
+
list the paths to seal by Scope; each path is checked against its Scope's settings type, so a misspelled path or Scope
|
|
335
|
+
doesn't compile. You call `set` and `get` with plain values, the schema validates the Plaintext, and the wrapped Store
|
|
336
|
+
only ever holds a Sealed value there. This
|
|
313
337
|
package doesn't depend on Secret Vault: `vault` only needs `encryptJson`, `decryptJson` and, optionally, `isSealed`
|
|
314
338
|
(without it, a string starting with `vault:` counts as sealed, which is what a `Vault` writes).
|
|
315
339
|
|
|
@@ -324,7 +348,7 @@ import { z } from "zod";
|
|
|
324
348
|
import { prisma } from "./db";
|
|
325
349
|
import { vault } from "./vault";
|
|
326
350
|
|
|
327
|
-
const
|
|
351
|
+
const organizationDefinition = defineSettings({
|
|
328
352
|
schema: z.object({
|
|
329
353
|
smtp: z.object({
|
|
330
354
|
host: z.string(),
|
|
@@ -334,27 +358,40 @@ const organization = defineSettings({
|
|
|
334
358
|
defaults: { smtp: { host: "localhost", password: null } },
|
|
335
359
|
});
|
|
336
360
|
|
|
361
|
+
const definitions = { organization: organizationDefinition };
|
|
362
|
+
|
|
337
363
|
const settings = createSettings({
|
|
338
|
-
definitions
|
|
364
|
+
definitions,
|
|
339
365
|
store: new SealedStore({
|
|
340
366
|
store: new PrismaStore({ delegate: prisma.setting }),
|
|
341
367
|
vault,
|
|
342
|
-
|
|
368
|
+
definitions,
|
|
369
|
+
paths: { organization: ["smtp.password"] },
|
|
343
370
|
}),
|
|
344
371
|
});
|
|
345
372
|
|
|
346
|
-
await settings
|
|
373
|
+
await settings
|
|
374
|
+
.scope("organization", organizationId)
|
|
375
|
+
.set("smtp.password", "hunter2");
|
|
347
376
|
const password = await settings
|
|
348
|
-
.scope("organization",
|
|
377
|
+
.scope("organization", organizationId)
|
|
349
378
|
.get("smtp.password"); // "hunter2"
|
|
350
379
|
```
|
|
351
380
|
|
|
352
381
|
- Each Sealed value is bound to its Owner and path (Context `settings:` plus `JSON.stringify([scope, id, path])`), so
|
|
353
382
|
one copied to another Owner or path doesn't open. A script that moves a Sealed setting must open it and seal it
|
|
354
383
|
again.
|
|
355
|
-
-
|
|
384
|
+
- Only the paths listed for an Owner's Scope are sealed: `smtp.password` in another Scope stays plain unless that Scope
|
|
385
|
+
lists it too. Two Scopes may list the same path.
|
|
386
|
+
- **A Scope left out of `paths` isn't sealed or opened.** If it already holds Sealed values, such as after you remove
|
|
387
|
+
it from `paths`, they read back as their raw `vault:…` strings. List every Scope that has a Sealed setting.
|
|
388
|
+
- A path may name an object, which is sealed whole as JSON. No path may be inside another of the same Scope. The
|
|
389
|
+
constructor throws `TypeError` for that, for an empty path, for a Scope that isn't in `definitions`, and when no
|
|
390
|
+
Scope lists a path.
|
|
356
391
|
- 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.
|
|
392
|
+
as a stale Override, because the next write would replace the secret with its default. Every other operation on
|
|
393
|
+
that Owner throws too, except `reset()`; to repair just that value, see
|
|
394
|
+
[Removing a Sealed value that won't open](#removing-a-sealed-value-that-wont-open).
|
|
358
395
|
- Every write seals again with the Vault's current secret, so a Rotation finishes for an Owner the next time it is
|
|
359
396
|
written.
|
|
360
397
|
- Adding a path to `paths` on a deployment that already has plain values there is safe: they read as before and are
|
|
@@ -364,11 +401,38 @@ const password = await settings
|
|
|
364
401
|
- Switching from values you sealed by hand: their Context differs, so open each with your old Context once and write
|
|
365
402
|
the Plaintext back through the settings with a script; `SealedStore` then seals it under its own Context.
|
|
366
403
|
|
|
404
|
+
### Removing a Sealed value that won't open
|
|
405
|
+
|
|
406
|
+
`discard(owner, path)` removes one Sealed value that won't open, so the Owner can read its settings and set a new
|
|
407
|
+
secret again. Its other Overrides stay as they are, and its other Sealed values stay sealed and aren't opened.
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
const sealedStore = new SealedStore({ store, vault, definitions, paths });
|
|
411
|
+
|
|
412
|
+
const removed = await sealedStore.discard(
|
|
413
|
+
{ scope: "organization", id: organizationId },
|
|
414
|
+
"smtp.password",
|
|
415
|
+
); // true when it removed a value; the Owner now reads the default there
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Check the Vault secret first.** A wrong or missing secret makes every Sealed value fail to open, so `discard` would
|
|
419
|
+
remove secrets that fixing the configuration would have recovered. Only discard once you know the right secret is
|
|
420
|
+
loaded and this one value still doesn't open, such as one sealed under a secret that was since removed.
|
|
421
|
+
|
|
422
|
+
- It returns `false` and changes nothing when the value opens, isn't a Sealed value, or isn't there, so running the
|
|
423
|
+
same repair again after the Owner set a new secret doesn't destroy it.
|
|
424
|
+
- It throws `TypeError` for a path not listed for the Owner's Scope. It takes one Owner and one path; there is no bulk
|
|
425
|
+
form.
|
|
426
|
+
- It writes with the version it read; on a conflict it reads again and retries up to 3 times, then throws
|
|
427
|
+
`SettingsConflictError`. A `CachedStore` underneath evicts the Owner on the conflict, so the retry reads fresh.
|
|
428
|
+
|
|
367
429
|
## Caching reads
|
|
368
430
|
|
|
369
431
|
`CachedStore` keeps what the Store it wraps returns for `ttl` seconds, so reading one Owner's settings in several
|
|
370
432
|
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
|
|
433
|
+
delete evicts the Owner, so two concurrent writes both take effect, and a write on this instance is read back at once by
|
|
434
|
+
this instance, even when one of its reads of that Owner was in flight during the write: a read that an eviction
|
|
435
|
+
overtook returns what it read but isn't cached.
|
|
372
436
|
|
|
373
437
|
```ts
|
|
374
438
|
import { CachedStore, MemoryCache, SealedStore } from "@rezwanul7/settings";
|
|
@@ -380,15 +444,20 @@ const store = new SealedStore({
|
|
|
380
444
|
ttl: 60,
|
|
381
445
|
}),
|
|
382
446
|
vault,
|
|
383
|
-
|
|
447
|
+
definitions,
|
|
448
|
+
paths: { organization: ["smtp.password"] },
|
|
384
449
|
});
|
|
385
450
|
```
|
|
386
451
|
|
|
387
452
|
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.
|
|
453
|
+
`onError` and the Store answers instead; a failing `delete` leaves the entry to expire with its TTL. `onError` isn't
|
|
454
|
+
awaited, and one that throws or returns a rejected promise doesn't change the operation, so a write still returns the
|
|
455
|
+
wrapped Store's result; the failure goes to `console.warn` once for each `CachedStore`.
|
|
389
456
|
|
|
390
457
|
`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
|
|
458
|
+
after a write elsewhere. Share a cache to avoid most of that. Even with a shared cache, a read on another instance that
|
|
459
|
+
was in flight during the write can still cache the old Overrides, which are then read for up to `ttl` seconds. Any
|
|
460
|
+
object with three string methods fits `SettingsCache`:
|
|
392
461
|
|
|
393
462
|
```ts
|
|
394
463
|
import type { SettingsCache } from "@rezwanul7/settings";
|
|
@@ -415,8 +484,8 @@ const redisCache: SettingsCache = {
|
|
|
415
484
|
Effective settings still fail it after ignoring stale Overrides. It is a bug in the app's Settings definitions, not
|
|
416
485
|
in the input, so don't show it to a user. It carries the `scope` and, for a failed validation, the `issues`.
|
|
417
486
|
|
|
418
|
-
`createSettings` also throws a `RangeError` for an invalid `maxRetries`, and `scope()` throws a `TypeError` for an
|
|
419
|
-
passed to a single-owner Scope or missing for any other, which the types already rule out.
|
|
487
|
+
`createSettings` also throws a `RangeError` for an invalid `maxRetries`, and `scope()` throws a `TypeError` for an
|
|
488
|
+
empty id, and for an id passed to a single-owner Scope or missing for any other, which the types already rule out.
|
|
420
489
|
|
|
421
490
|
```ts
|
|
422
491
|
import { SettingsValidationError } from "@rezwanul7/settings";
|
package/dist/index.cjs
CHANGED
|
@@ -249,10 +249,39 @@ function coveringOverride(overrides, keys) {
|
|
|
249
249
|
return [...keys];
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// src/warn-once.ts
|
|
253
|
+
function warnOnce(message) {
|
|
254
|
+
let warned = false;
|
|
255
|
+
return (failure) => {
|
|
256
|
+
if (warned) return;
|
|
257
|
+
warned = true;
|
|
258
|
+
console.warn(message, failure);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function guardHook(hook, message) {
|
|
262
|
+
if (hook === void 0) return void 0;
|
|
263
|
+
const warn = warnOnce(message);
|
|
264
|
+
return (...args) => {
|
|
265
|
+
try {
|
|
266
|
+
const result = hook(...args);
|
|
267
|
+
if (isThenable(result)) Promise.resolve(result).catch(warn);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
warn(error);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function isThenable(value) {
|
|
274
|
+
return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
|
|
275
|
+
}
|
|
276
|
+
|
|
252
277
|
// src/settings.ts
|
|
253
278
|
var SINGLE_OWNER_ID = "default";
|
|
254
279
|
function createSettings(options) {
|
|
255
|
-
const { definitions, store,
|
|
280
|
+
const { definitions, store, maxRetries = 3 } = options;
|
|
281
|
+
const onInvalid = guardHook(
|
|
282
|
+
options.onInvalid,
|
|
283
|
+
"@rezwanul7/settings: onInvalid failed, and the operation went on without it. Later failures of this onInvalid aren't reported."
|
|
284
|
+
);
|
|
256
285
|
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
|
|
257
286
|
throw new RangeError(
|
|
258
287
|
`maxRetries must be a whole number, 0 or more; got ${maxRetries}.`
|
|
@@ -417,6 +446,9 @@ function ownerOf(scope, definition, id) {
|
|
|
417
446
|
if (typeof id !== "string") {
|
|
418
447
|
throw new TypeError(`Scope "${scope}" needs an Owner id.`);
|
|
419
448
|
}
|
|
449
|
+
if (id === "") {
|
|
450
|
+
throw new TypeError(`Scope "${scope}" needs a non-empty Owner id.`);
|
|
451
|
+
}
|
|
420
452
|
return { scope, id };
|
|
421
453
|
}
|
|
422
454
|
function rejectForbiddenKeys(path, value) {
|
|
@@ -470,6 +502,7 @@ function asJson(overrides) {
|
|
|
470
502
|
}
|
|
471
503
|
|
|
472
504
|
// src/sealed-store.ts
|
|
505
|
+
var DISCARD_RETRIES = 3;
|
|
473
506
|
function defaultIsSealed(value) {
|
|
474
507
|
return typeof value === "string" && value.startsWith("vault:");
|
|
475
508
|
}
|
|
@@ -481,22 +514,46 @@ var SealedStore = class {
|
|
|
481
514
|
#vault;
|
|
482
515
|
#paths;
|
|
483
516
|
/**
|
|
484
|
-
* @param options - The Store to wrap, the Vault and the sealed paths.
|
|
485
|
-
* @throws `TypeError` when a path is empty or
|
|
517
|
+
* @param options - The Store to wrap, the Vault, the Settings definitions and the sealed paths by Scope.
|
|
518
|
+
* @throws `TypeError` when no Scope lists a path, a Scope in `paths` isn't in `definitions`, a path is empty, or a
|
|
519
|
+
* path is inside another of the same Scope.
|
|
486
520
|
*/
|
|
487
521
|
constructor(options) {
|
|
488
522
|
this.#store = options.store;
|
|
489
523
|
this.#vault = options.vault;
|
|
490
|
-
|
|
491
|
-
for (const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (
|
|
524
|
+
const paths = /* @__PURE__ */ new Map();
|
|
525
|
+
for (const [scope, list] of Object.entries(
|
|
526
|
+
options.paths
|
|
527
|
+
)) {
|
|
528
|
+
if (!Object.hasOwn(options.definitions, scope)) {
|
|
495
529
|
throw new TypeError(
|
|
496
|
-
`SealedStore:
|
|
530
|
+
`SealedStore: Scope "${scope}" in paths isn't in definitions.`
|
|
497
531
|
);
|
|
498
532
|
}
|
|
533
|
+
const unique = [...new Set(list ?? [])];
|
|
534
|
+
for (const path of unique) {
|
|
535
|
+
if (path === "") {
|
|
536
|
+
throw new TypeError(
|
|
537
|
+
`SealedStore: a path of Scope "${scope}" is empty.`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const inner = unique.find((p) => p.startsWith(`${path}.`));
|
|
541
|
+
if (inner !== void 0) {
|
|
542
|
+
throw new TypeError(
|
|
543
|
+
`SealedStore: path "${inner}" is inside path "${path}" in Scope "${scope}".`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (unique.length > 0) paths.set(scope, unique);
|
|
548
|
+
}
|
|
549
|
+
if (paths.size === 0) {
|
|
550
|
+
throw new TypeError("SealedStore: no Scope lists a path to seal.");
|
|
499
551
|
}
|
|
552
|
+
this.#paths = paths;
|
|
553
|
+
}
|
|
554
|
+
/** The paths sealed for the Owner's Scope. */
|
|
555
|
+
#pathsOf(owner) {
|
|
556
|
+
return this.#paths.get(owner.scope) ?? [];
|
|
500
557
|
}
|
|
501
558
|
/** {@inheritDoc SettingsStore.read} */
|
|
502
559
|
async read(owner) {
|
|
@@ -504,7 +561,7 @@ var SealedStore = class {
|
|
|
504
561
|
if (stored === null) return null;
|
|
505
562
|
const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
|
|
506
563
|
let overrides = stored.overrides;
|
|
507
|
-
for (const path of this.#
|
|
564
|
+
for (const path of this.#pathsOf(owner)) {
|
|
508
565
|
const value = getAt(overrides, path);
|
|
509
566
|
if (!isSealed(value)) continue;
|
|
510
567
|
const plaintext = await this.#vault.decryptJson(
|
|
@@ -517,18 +574,74 @@ var SealedStore = class {
|
|
|
517
574
|
}
|
|
518
575
|
/** {@inheritDoc SettingsStore.write} */
|
|
519
576
|
async write(owner, overrides, expectedVersion) {
|
|
577
|
+
return this.#store.write(
|
|
578
|
+
owner,
|
|
579
|
+
await this.#seal(owner, overrides, () => false),
|
|
580
|
+
expectedVersion
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Seals the values at the paths listed for the Owner's Scope, skipping those `skip` matches; `discard` skips the
|
|
585
|
+
* values already sealed, so it never opens or seals them again.
|
|
586
|
+
*/
|
|
587
|
+
async #seal(owner, overrides, skip) {
|
|
520
588
|
let sealed = overrides;
|
|
521
|
-
for (const path of this.#
|
|
589
|
+
for (const path of this.#pathsOf(owner)) {
|
|
522
590
|
if (!hasAt(sealed, path)) continue;
|
|
523
591
|
const value = getAt(sealed, path);
|
|
524
|
-
if (value === void 0) continue;
|
|
592
|
+
if (value === void 0 || skip(value)) continue;
|
|
525
593
|
sealed = setAt(
|
|
526
594
|
sealed,
|
|
527
595
|
path,
|
|
528
596
|
await this.#vault.encryptJson(value, contextOf(owner, path))
|
|
529
597
|
);
|
|
530
598
|
}
|
|
531
|
-
return
|
|
599
|
+
return sealed;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Removes one Sealed value that won't open, so the Owner can read its settings and set a new secret again; its
|
|
603
|
+
* other Sealed values stay sealed and aren't opened, and a plain value at another listed path is sealed, as on any
|
|
604
|
+
* write. Its other Overrides stay as they are. A value that opens, a
|
|
605
|
+
* plain value and an empty path are left alone, so running it again after the Owner set a new secret changes
|
|
606
|
+
* nothing. Any rejection from `decryptJson` counts as not opening.
|
|
607
|
+
*
|
|
608
|
+
* **Check the Vault secret first.** With a wrong or missing secret every Sealed value fails to open, so `discard`
|
|
609
|
+
* would remove secrets that fixing the configuration would have recovered.
|
|
610
|
+
*
|
|
611
|
+
* It reads the wrapped Store and writes with the version it read. On a conflict it reads again and retries, up to 3
|
|
612
|
+
* times; a {@link CachedStore} underneath evicts on the conflict, so the retry reads fresh Overrides.
|
|
613
|
+
*
|
|
614
|
+
* @param owner - The Owner whose Sealed value to remove.
|
|
615
|
+
* @param path - A path listed for the Owner's Scope, such as `"smtp.password"`.
|
|
616
|
+
* @returns `true` when a Sealed value that won't open was removed, `false` when nothing was changed.
|
|
617
|
+
* @throws `TypeError` when `path` isn't listed for the Owner's Scope.
|
|
618
|
+
* @throws {@link SettingsConflictError} when every write lost the version race to another write.
|
|
619
|
+
*/
|
|
620
|
+
async discard(owner, path) {
|
|
621
|
+
if (!this.#pathsOf(owner).includes(path)) {
|
|
622
|
+
throw new TypeError(
|
|
623
|
+
`SealedStore: path "${path}" isn't sealed in Scope "${owner.scope}".`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
|
|
627
|
+
for (let attempt = 0; attempt <= DISCARD_RETRIES; attempt++) {
|
|
628
|
+
const stored = await this.#store.read(owner);
|
|
629
|
+
if (stored === null || !hasAt(stored.overrides, path)) return false;
|
|
630
|
+
const value = getAt(stored.overrides, path);
|
|
631
|
+
if (!isSealed(value)) return false;
|
|
632
|
+
const opens = await this.#vault.decryptJson(value, contextOf(owner, path)).then(
|
|
633
|
+
() => true,
|
|
634
|
+
() => false
|
|
635
|
+
);
|
|
636
|
+
if (opens) return false;
|
|
637
|
+
const version = await this.#store.write(
|
|
638
|
+
owner,
|
|
639
|
+
await this.#seal(owner, removeAt(stored.overrides, path), isSealed),
|
|
640
|
+
stored.version
|
|
641
|
+
);
|
|
642
|
+
if (version !== null) return true;
|
|
643
|
+
}
|
|
644
|
+
throw new SettingsConflictError(owner, DISCARD_RETRIES);
|
|
532
645
|
}
|
|
533
646
|
/** {@inheritDoc SettingsStore.delete} */
|
|
534
647
|
async delete(owner) {
|
|
@@ -543,6 +656,12 @@ var CachedStore = class {
|
|
|
543
656
|
#ttl;
|
|
544
657
|
#prefix;
|
|
545
658
|
#onError;
|
|
659
|
+
/**
|
|
660
|
+
* The Owners with a read of the wrapped Store in flight, by cache key: how many such reads there are, and how many
|
|
661
|
+
* evictions happened since the first began. A read caches only if no eviction happened during it. An entry goes
|
|
662
|
+
* when its last read finishes, so this holds only the Owners being read right now.
|
|
663
|
+
*/
|
|
664
|
+
#inFlight = /* @__PURE__ */ new Map();
|
|
546
665
|
/**
|
|
547
666
|
* @param options - The Store to wrap, the cache and the TTL.
|
|
548
667
|
* @throws `RangeError` when `ttl` isn't a positive number.
|
|
@@ -555,7 +674,10 @@ var CachedStore = class {
|
|
|
555
674
|
this.#cache = options.cache;
|
|
556
675
|
this.#ttl = options.ttl;
|
|
557
676
|
this.#prefix = options.prefix ?? "settings";
|
|
558
|
-
this.#onError =
|
|
677
|
+
this.#onError = guardHook(
|
|
678
|
+
options.onError,
|
|
679
|
+
"@rezwanul7/settings: CachedStore onError failed, and the operation went on without it. Later failures of this onError aren't reported."
|
|
680
|
+
);
|
|
559
681
|
}
|
|
560
682
|
/** {@inheritDoc SettingsStore.read} */
|
|
561
683
|
async read(owner) {
|
|
@@ -568,13 +690,33 @@ var CachedStore = class {
|
|
|
568
690
|
} catch (error) {
|
|
569
691
|
this.#report(error);
|
|
570
692
|
}
|
|
571
|
-
|
|
693
|
+
let reading = this.#inFlight.get(key2);
|
|
694
|
+
if (reading === void 0) {
|
|
695
|
+
reading = { reads: 0, evictions: 0 };
|
|
696
|
+
this.#inFlight.set(key2, reading);
|
|
697
|
+
}
|
|
698
|
+
reading.reads++;
|
|
699
|
+
const evictionsBefore = reading.evictions;
|
|
572
700
|
try {
|
|
573
|
-
await this.#
|
|
574
|
-
|
|
575
|
-
|
|
701
|
+
const stored = await this.#store.read(owner);
|
|
702
|
+
if (reading.evictions === evictionsBefore) {
|
|
703
|
+
try {
|
|
704
|
+
await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
this.#report(error);
|
|
707
|
+
}
|
|
708
|
+
if (reading.evictions !== evictionsBefore) {
|
|
709
|
+
try {
|
|
710
|
+
await this.#cache.delete(key2);
|
|
711
|
+
} catch (error) {
|
|
712
|
+
this.#report(error);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return stored;
|
|
717
|
+
} finally {
|
|
718
|
+
if (--reading.reads === 0) this.#inFlight.delete(key2);
|
|
576
719
|
}
|
|
577
|
-
return stored;
|
|
578
720
|
}
|
|
579
721
|
/** {@inheritDoc SettingsStore.write} */
|
|
580
722
|
async write(owner, overrides, expectedVersion) {
|
|
@@ -593,8 +735,11 @@ var CachedStore = class {
|
|
|
593
735
|
}
|
|
594
736
|
}
|
|
595
737
|
async #evict(owner) {
|
|
738
|
+
const key2 = this.#key(owner);
|
|
739
|
+
const reading = this.#inFlight.get(key2);
|
|
740
|
+
if (reading !== void 0) reading.evictions++;
|
|
596
741
|
try {
|
|
597
|
-
await this.#cache.delete(
|
|
742
|
+
await this.#cache.delete(key2);
|
|
598
743
|
} catch (error) {
|
|
599
744
|
this.#report(error);
|
|
600
745
|
}
|