@rezwanul7/settings 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -60
- package/dist/index.cjs +332 -1
- package/dist/index.d.cts +278 -14
- package/dist/index.d.ts +278 -14
- package/dist/index.js +330 -2
- package/dist/prisma.cjs +4 -1
- package/dist/prisma.d.cts +10 -10
- package/dist/prisma.d.ts +10 -10
- package/dist/prisma.js +4 -1
- package/package.json +2 -1
package/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,63 +324,155 @@ 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
|
-
|
|
311
|
-
[`@rezwanul7/secret-vault`](../secret-vault/README.md)
|
|
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
|
|
337
|
+
package doesn't depend on Secret Vault: `vault` only needs `encryptJson`, `decryptJson` and, optionally, `isSealed`
|
|
338
|
+
(without it, a string starting with `vault:` counts as sealed, which is what a `Vault` writes).
|
|
313
339
|
|
|
314
340
|
```ts
|
|
315
341
|
import {
|
|
316
342
|
createSettings,
|
|
317
343
|
defineSettings,
|
|
318
|
-
|
|
344
|
+
SealedStore,
|
|
319
345
|
} from "@rezwanul7/settings";
|
|
320
346
|
import { PrismaStore } from "@rezwanul7/settings/prisma";
|
|
321
347
|
import { z } from "zod";
|
|
322
348
|
import { prisma } from "./db";
|
|
323
349
|
import { vault } from "./vault";
|
|
324
350
|
|
|
325
|
-
const
|
|
351
|
+
const organizationDefinition = defineSettings({
|
|
326
352
|
schema: z.object({
|
|
327
353
|
smtp: z.object({
|
|
328
354
|
host: z.string(),
|
|
329
|
-
|
|
330
|
-
password: z.string().startsWith("vault:").nullable(),
|
|
355
|
+
password: z.string().min(1).nullable(),
|
|
331
356
|
}),
|
|
332
357
|
}),
|
|
333
358
|
defaults: { smtp: { host: "localhost", password: null } },
|
|
334
359
|
});
|
|
335
360
|
|
|
361
|
+
const definitions = { organization: organizationDefinition };
|
|
362
|
+
|
|
336
363
|
const settings = createSettings({
|
|
337
|
-
definitions
|
|
338
|
-
store: new
|
|
364
|
+
definitions,
|
|
365
|
+
store: new SealedStore({
|
|
366
|
+
store: new PrismaStore({ delegate: prisma.setting }),
|
|
367
|
+
vault,
|
|
368
|
+
definitions,
|
|
369
|
+
paths: { organization: ["smtp.password"] },
|
|
370
|
+
}),
|
|
339
371
|
});
|
|
340
372
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
373
|
+
await settings
|
|
374
|
+
.scope("organization", organizationId)
|
|
375
|
+
.set("smtp.password", "hunter2");
|
|
376
|
+
const password = await settings
|
|
377
|
+
.scope("organization", organizationId)
|
|
378
|
+
.get("smtp.password"); // "hunter2"
|
|
379
|
+
```
|
|
344
380
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
381
|
+
- Each Sealed value is bound to its Owner and path (Context `settings:` plus `JSON.stringify([scope, id, path])`), so
|
|
382
|
+
one copied to another Owner or path doesn't open. A script that moves a Sealed setting must open it and seal it
|
|
383
|
+
again.
|
|
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.
|
|
391
|
+
- A Sealed value that doesn't open, such as after a wrong Vault secret, makes the read **throw**. It is never ignored
|
|
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).
|
|
395
|
+
- Every write seals again with the Vault's current secret, so a Rotation finishes for an Owner the next time it is
|
|
396
|
+
written.
|
|
397
|
+
- Adding a path to `paths` on a deployment that already has plain values there is safe: they read as before and are
|
|
398
|
+
sealed on each Owner's next write. Values at unlisted paths are never opened.
|
|
399
|
+
- The Effective settings now hold the **Plaintext**. Don't log them or pass them to the client; read the secret on the
|
|
400
|
+
server, only where it is used.
|
|
401
|
+
- Switching from values you sealed by hand: their Context differs, so open each with your old Context once and write
|
|
402
|
+
the Plaintext back through the settings with a script; `SealedStore` then seals it under its own Context.
|
|
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.
|
|
350
408
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
+
|
|
429
|
+
## Caching reads
|
|
430
|
+
|
|
431
|
+
`CachedStore` keeps what the Store it wraps returns for `ttl` seconds, so reading one Owner's settings in several
|
|
432
|
+
requests doesn't query the database each time. An Owner with nothing stored is cached too. Every write, conflict and
|
|
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.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
import { CachedStore, MemoryCache, SealedStore } from "@rezwanul7/settings";
|
|
439
|
+
|
|
440
|
+
const store = new SealedStore({
|
|
441
|
+
store: new CachedStore({
|
|
442
|
+
store: new PrismaStore({ delegate: prisma.setting }),
|
|
443
|
+
cache: new MemoryCache({ maxEntries: 5000 }),
|
|
444
|
+
ttl: 60,
|
|
445
|
+
}),
|
|
446
|
+
vault,
|
|
447
|
+
definitions,
|
|
448
|
+
paths: { organization: ["smtp.password"] },
|
|
449
|
+
});
|
|
358
450
|
```
|
|
359
451
|
|
|
360
|
-
|
|
361
|
-
|
|
452
|
+
Nest `SealedStore` outermost so the cache only holds Sealed values. A cache that throws is passed to the optional
|
|
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`.
|
|
456
|
+
|
|
457
|
+
`MemoryCache` is per process. With several instances, another instance can serve Overrides up to `ttl` seconds old
|
|
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`:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import type { SettingsCache } from "@rezwanul7/settings";
|
|
464
|
+
import { redis } from "./redis";
|
|
465
|
+
|
|
466
|
+
const redisCache: SettingsCache = {
|
|
467
|
+
get: async (key) => (await redis.get(key)) ?? undefined,
|
|
468
|
+
set: async (key, value, ttlSeconds) => {
|
|
469
|
+
await redis.set(key, value, "EX", Math.ceil(ttlSeconds));
|
|
470
|
+
},
|
|
471
|
+
delete: async (key) => {
|
|
472
|
+
await redis.del(key);
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
```
|
|
362
476
|
|
|
363
477
|
## Errors
|
|
364
478
|
|
|
@@ -370,8 +484,8 @@ secret; open it on the server, only where it is used.
|
|
|
370
484
|
Effective settings still fail it after ignoring stale Overrides. It is a bug in the app's Settings definitions, not
|
|
371
485
|
in the input, so don't show it to a user. It carries the `scope` and, for a failed validation, the `issues`.
|
|
372
486
|
|
|
373
|
-
`createSettings` also throws a `RangeError` for an invalid `maxRetries`, and `scope()` throws a `TypeError` for an
|
|
374
|
-
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.
|
|
375
489
|
|
|
376
490
|
```ts
|
|
377
491
|
import { SettingsValidationError } from "@rezwanul7/settings";
|