@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 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 user = defineSettings({
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 global = defineSettings({
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, 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.
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 strings. A
87
- single-owner Scope takes no id; any other Scope requires one, and both rules are checked by the types.
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 { global, user } from "./settings-definitions";
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 settings", owner, issues);
104
+ console.warn("Ignored stale Overrides", owner, issues);
101
105
  },
102
106
  maxRetries: 3,
103
107
  });
104
108
 
105
- const userSettings = settings.scope("user", "42");
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 me = settings.scope("user", "42");
130
+ const user42Settings = settings.scope("user", "42");
127
131
 
128
- await me.get(); // { theme: "system", language: "en", notifications: { email: true, push: false } }
129
- await me.get("notifications.email"); // true
132
+ await user42Settings.get(); // { theme: "system", language: "en", notifications: { email: true, push: false } }
133
+ await user42Settings.get("notifications.email"); // true
130
134
 
131
- await me.set("theme", "dark");
132
- await me.update({ language: "de", notifications: { push: true } });
133
- await me.getOverrides(); // { theme: "dark", language: "de", notifications: { push: true } }
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 me.reset("theme"); // theme follows the default again
136
- await me.reset(); // deletes everything stored for the Owner
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 a row created after it starts again at version 1. So a write that read the Owner
205
- just before a `reset()`, and stores after another write has recreated the row and brought it back to the version it
206
- read, overwrites that row: the Overrides written since the reset are lost. The model keeps no version once the row is
207
- gone, so the Store can't prevent that; call `reset()` when no other write to the Owner is expected, such as when
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 { global, user } from "./settings-definitions";
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 { global, user } from "./settings-definitions";
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 settings", { owner, issues });
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` can't, see
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
- ## Secret settings
330
+ ## Sealed settings
309
331
 
310
- The package doesn't encrypt anything. Keep a secret setting, such as an SMTP password, sealed with
311
- [`@rezwanul7/secret-vault`](../secret-vault/README.md): seal it before `set()`, store the Sealed value as a string,
312
- and open it after `get()`. Give the Context the Owner, so a Sealed value copied to another Owner doesn't open.
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
- type Owner,
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 organization = defineSettings({
351
+ const organizationDefinition = defineSettings({
326
352
  schema: z.object({
327
353
  smtp: z.object({
328
354
  host: z.string(),
329
- // A Sealed value made by the Vault, never the password itself.
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: { organization },
338
- store: new PrismaStore({ delegate: prisma.setting }),
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
- function smtpPasswordContext(owner: Owner): string {
342
- return `settings:smtp.password:${owner.scope}:${owner.id}`;
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
- export async function saveSmtpPassword(orgId: string, password: string) {
346
- const org = settings.scope("organization", orgId);
347
- const sealed = await vault.encrypt(password, smtpPasswordContext(org.owner));
348
- await org.set("smtp.password", sealed);
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
- export async function readSmtpPassword(orgId: string): Promise<string | null> {
352
- const org = settings.scope("organization", orgId);
353
- const sealed = await org.get("smtp.password");
354
- return sealed === null
355
- ? null
356
- : vault.decrypt(sealed, smtpPasswordContext(org.owner));
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
- The Effective settings then hold only the Sealed value, so they can be logged or passed around without exposing the
361
- secret; open it on the server, only where it is used.
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 id
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";