@rezwanul7/settings 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rezwanul Islam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,389 @@
1
- # Temporary Holding Version
1
+ # @rezwanul7/settings
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Per-user and app-wide settings for an app, such as a user's theme and language or a global maintenance banner. Each
4
+ Scope declares a schema and its defaults once; only an Owner's own choices, its Overrides, are stored, as one JSON
5
+ document per Owner, and every read returns the Effective settings: the defaults with the Overrides applied. Adding a
6
+ field or changing a default needs no migration of stored rows. Writes are validated and guarded by a version, so two
7
+ requests changing one Owner's settings at once both take effect. The terms used here are defined in the package
8
+ [glossary](./GLOSSARY.md).
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @rezwanul7/settings
14
+ # or
15
+ pnpm add @rezwanul7/settings
16
+ ```
17
+
18
+ The package has no runtime or peer dependencies. Schemas can come from any validator that implements
19
+ [Standard Schema](https://standardschema.dev), such as Zod, Valibot or ArkType; the examples use Zod.
20
+
21
+ ## Entry points
22
+
23
+ | Import | Contents |
24
+ | ---------------------------- | -------------------------------------------------------------------------- |
25
+ | `@rezwanul7/settings` | `defineSettings`, `createSettings`, `MemoryStore`, every type and error |
26
+ | `@rezwanul7/settings/prisma` | `PrismaStore` and the delegate type it accepts; doesn't need Prisma itself |
27
+
28
+ ## Defining Scopes
29
+
30
+ A Scope is a kind of Owner, such as `user` or `organization`. Give each Scope one Settings definition with
31
+ `defineSettings`: a schema and the defaults used wherever an Owner has no Override. The settings type is inferred from
32
+ the schema's output type, and `defaults` must be a complete value of it; optional fields may be left out.
33
+
34
+ A single-owner Scope, such as `global` for app-wide settings, has exactly one Owner. Mark it with `singleOwner: true`.
35
+
36
+ ```ts
37
+ // src/server/settings-definitions.ts
38
+ import { defineSettings } from "@rezwanul7/settings";
39
+ import { z } from "zod";
40
+
41
+ export const user = defineSettings({
42
+ schema: z.object({
43
+ theme: z.enum(["light", "dark", "system"]),
44
+ language: z.string(),
45
+ notifications: z.object({ email: z.boolean(), push: z.boolean() }),
46
+ }),
47
+ defaults: {
48
+ theme: "system",
49
+ language: "en",
50
+ notifications: { email: true, push: false },
51
+ },
52
+ });
53
+
54
+ export const global = defineSettings({
55
+ schema: z.object({ maintenanceBanner: z.string().nullable() }),
56
+ defaults: { maintenanceBanner: null },
57
+ singleOwner: true,
58
+ });
59
+ ```
60
+
61
+ The defaults are checked against the schema on the first operation on each Scope, because Standard Schema validation
62
+ may be async. Defaults that fail it throw `SettingsDefinitionError` there, on every operation on that Scope. If the
63
+ validation itself throws, such as an async validator that can't reach its service, that operation rejects with the
64
+ error and the next one checks again.
65
+
66
+ ### Keep the schema's output valid input
67
+
68
+ Every read merges the Overrides over the defaults and validates the result, and every write validates the Effective
69
+ settings it would produce. What is stored is the change as given, not the schema's output. So the defaults, which have
70
+ the output type, and the stored Overrides are validated again on every read, and a schema whose output doesn't pass
71
+ the schema again breaks:
72
+
73
+ - Don't use transforms that change a value's type, such as a string parsed into a `Date`: the defaults (a `Date`)
74
+ would then fail the schema, which expects a string. A transform that keeps the type, such as trimming, works, but
75
+ runs on every read rather than once on write.
76
+ - Don't use schema-level defaults, such as Zod's `.default()`. The Settings definition's `defaults` are the one place
77
+ defaults live, and every field already has one there.
78
+ - Use only JSON values: strings, finite numbers, booleans, `null`, arrays and plain objects. Every Store keeps the
79
+ Overrides as JSON, so a `Date` would read back as its ISO string and an `undefined` value would be dropped. For a
80
+ date, store the string and convert it where the setting is used. `MemoryStore` keeps them as JSON too, so tests
81
+ show what a database does.
82
+
83
+ ## Creating the settings
84
+
85
+ Pass every Settings definition, by Scope name, and a Store to `createSettings`. Then `scope(name, id)` gives one
86
+ Owner's `ScopedSettings`. Owner ids are strings. A single-owner Scope takes no id; any other Scope requires one, and
87
+ both rules are checked by the types.
88
+
89
+ ```ts
90
+ import { createSettings, MemoryStore } from "@rezwanul7/settings";
91
+ import { global, user } from "./settings-definitions";
92
+
93
+ const settings = createSettings({
94
+ definitions: { user, global },
95
+ store: new MemoryStore(),
96
+ onInvalid: (owner, issues) => {
97
+ console.warn("Ignored stale settings", owner, issues);
98
+ },
99
+ maxRetries: 3,
100
+ });
101
+
102
+ const userSettings = settings.scope("user", "42");
103
+ const globalSettings = settings.scope("global");
104
+ ```
105
+
106
+ | Option | Default | What it does |
107
+ | ------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |
108
+ | `definitions` | (required) | Each Scope's Settings definition, by Scope name. |
109
+ | `store` | (required) | Where the Overrides are kept: `MemoryStore`, `PrismaStore` or your own `SettingsStore`. |
110
+ | `onInvalid` | none | Called with the Owner and the issues when stored Overrides are ignored; see [Stale Overrides](#stale-overrides). |
111
+ | `maxRetries` | `3` | How often a write that lost a version race tries again; see [Concurrent writes](#concurrent-writes). |
112
+
113
+ `maxRetries` must be a whole number, 0 or more, or `createSettings` throws a `RangeError`. With `0`, a write never
114
+ retries.
115
+
116
+ `MemoryStore` keeps Overrides in process memory, for tests and prototypes; they are lost when the process exits and
117
+ aren't shared between processes. For a database, use the [Prisma Store](#storing-settings-with-prisma) or implement
118
+ `SettingsStore` (its contract is in the [API reference](./docs/api/README.md#settingsstore)).
119
+
120
+ ## Reading and writing an Owner's settings
121
+
122
+ ```ts
123
+ const me = settings.scope("user", "42");
124
+
125
+ await me.get(); // { theme: "system", language: "en", notifications: { email: true, push: false } }
126
+ await me.get("notifications.email"); // true
127
+
128
+ await me.set("theme", "dark");
129
+ await me.update({ language: "de", notifications: { push: true } });
130
+ await me.getOverrides(); // { theme: "dark", language: "de", notifications: { push: true } }
131
+
132
+ await me.reset("theme"); // theme follows the default again
133
+ await me.reset(); // deletes everything stored for the Owner
134
+ ```
135
+
136
+ | Member | What it does |
137
+ | ------------------ | ------------------------------------------------------------------------------------------- |
138
+ | `owner` | The Owner, `{ scope, id }`. The Owner of a single-owner Scope has the id `"default"`. |
139
+ | `get()` | The complete Effective settings, as the schema outputs them. |
140
+ | `get(path)` | One typed value of the Effective settings, at a dot path such as `"notifications.email"`. |
141
+ | `set(path, value)` | Stores `value` as the Override at `path`, keeping the other Overrides. |
142
+ | `update(partial)` | Deep-merges `partial` into the Overrides, to change several settings at once. |
143
+ | `getOverrides()` | The Overrides as stored, without the defaults, or `{}` when nothing is stored. |
144
+ | `reset(path)` | Removes the Override at `path` (and every Override under it), so the default applies again. |
145
+ | `reset()` | Deletes everything stored for the Owner, so it reads the defaults. |
146
+
147
+ - Paths and values are type-checked: `set("theme", "blue")` and `get("colour")` don't compile. A path never goes into
148
+ an array; an array is one value, set and reset as a whole.
149
+ - `__proto__`, `constructor` and `prototype` can't be setting names, at any depth, because they could reach
150
+ `Object.prototype`. A write with one in its path or value throws `SettingsValidationError` with a `"Forbidden key"`
151
+ issue, and `get(path)` reads one as `undefined`. Stored ones are ignored as unknown keys.
152
+ - Merging works the same on reads and in `update`: plain objects merge key by key; arrays, scalars and `null` replace.
153
+ - 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.
155
+ - A value equal to the current default is still stored as an Override, so a later change of the default doesn't change
156
+ what the Owner chose. `reset(path)` is how an Owner follows the default again; with no Override at `path`, it
157
+ writes nothing.
158
+ - A write whose Effective settings fail the schema throws `SettingsValidationError` and stores nothing. So does a
159
+ write with a key the schema doesn't know, which a schema that strips unknown keys would otherwise drop silently; the
160
+ 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; it returns the document as
162
+ stored, including any Overrides a read would ignore.
163
+
164
+ ### Authorize the Owner first
165
+
166
+ An Owner id is data, not a credential: the package stores and returns whatever Owner the app names, and checks
167
+ nothing about who is asking. Before calling `scope(name, id)`, the app must decide that the current request may read
168
+ or change that Owner's settings, for example by taking the user id from the session rather than from the request
169
+ body, and by checking that the user is an admin before writing a `global` setting.
170
+
171
+ ### Deleting an Owner
172
+
173
+ Settings aren't removed when the user or organization they belong to is deleted. Call `reset()` for that Owner as
174
+ part of deleting it, so no Overrides are left behind.
175
+
176
+ ## Storing settings with Prisma
177
+
178
+ `@rezwanul7/settings/prisma` exports `PrismaStore`, a Store over your own Prisma model. The package doesn't depend on
179
+ `@prisma/client`; it takes the model delegate your generated client provides. Copy this model into your Prisma schema
180
+ and create a migration:
181
+
182
+ ```prisma
183
+ model Setting {
184
+ scope String
185
+ ownerId String
186
+ overrides Json
187
+ version Int @default(1)
188
+ createdAt DateTime @default(now())
189
+ updatedAt DateTime @updatedAt
190
+
191
+ @@id([scope, ownerId])
192
+ @@map("settings")
193
+ }
194
+ ```
195
+
196
+ Each row holds one Owner's Overrides; an Owner with nothing stored has no row and reads the defaults. Keep the field
197
+ names, the compound id on `scope` and `ownerId`, and the `version` default of `1`; the model and table names can be
198
+ changed, as long as you pass the matching delegate.
199
+
200
+ The row is removed by `reset()`, and a row created after it starts again at version 1. So a write that read the Owner
201
+ just before a `reset()`, and stores after another write has recreated the row and brought it back to the version it
202
+ read, overwrites that row: the Overrides written since the reset are lost. The model keeps no version once the row is
203
+ gone, so the Store can't prevent that; call `reset()` when no other write to the Owner is expected, such as when
204
+ deleting the Owner.
205
+
206
+ ```ts
207
+ import { createSettings } from "@rezwanul7/settings";
208
+ import { PrismaStore } from "@rezwanul7/settings/prisma";
209
+ import { prisma } from "./db";
210
+ import { global, user } from "./settings-definitions";
211
+
212
+ export const settings = createSettings({
213
+ definitions: { user, global },
214
+ store: new PrismaStore({ delegate: prisma.setting }),
215
+ });
216
+ ```
217
+
218
+ ## Next.js
219
+
220
+ Create the settings once, in a server module, and wrap `get()` in React's `cache()` so a request that reads an
221
+ Owner's settings in several Server Components reads the database once. Take the Owner id from the session, never from
222
+ the client.
223
+
224
+ ```ts
225
+ // src/server/settings.ts
226
+ import "server-only";
227
+ import { cache } from "react";
228
+ import { createSettings } from "@rezwanul7/settings";
229
+ import { PrismaStore } from "@rezwanul7/settings/prisma";
230
+ import { auth } from "./auth";
231
+ import { prisma } from "./db";
232
+ import { global, user } from "./settings-definitions";
233
+
234
+ export const settings = createSettings({
235
+ definitions: { user, global },
236
+ store: new PrismaStore({ delegate: prisma.setting }),
237
+ onInvalid: (owner, issues) => {
238
+ console.warn("Ignored stale settings", { owner, issues });
239
+ },
240
+ });
241
+
242
+ /** The signed-in user's Effective settings, read once per request. */
243
+ export const getMySettings = cache(async () => {
244
+ const session = await auth();
245
+ if (!session) throw new Error("Not signed in");
246
+ return settings.scope("user", session.user.id).get();
247
+ });
248
+
249
+ /** The app-wide settings, read once per request. */
250
+ export const getGlobalSettings = cache(() => settings.scope("global").get());
251
+ ```
252
+
253
+ ```ts
254
+ // src/app/settings/actions.ts
255
+ "use server";
256
+ import { auth } from "@/server/auth";
257
+ import { settings } from "@/server/settings";
258
+
259
+ export async function setTheme(theme: "light" | "dark" | "system") {
260
+ const session = await auth();
261
+ if (!session) throw new Error("Not signed in");
262
+ await settings.scope("user", session.user.id).set("theme", theme);
263
+ }
264
+ ```
265
+
266
+ `cache()` lasts for one server request. A Server Action runs in its own request, so the page it revalidates reads the
267
+ new value; code that writes and then reads in the same request should call `scope(...).get()` directly rather than the
268
+ cached function.
269
+
270
+ ## Stale Overrides
271
+
272
+ A schema change can make stored Overrides invalid, for example when an enum value is renamed or a field is removed.
273
+ Such an Override is ignored rather than thrown, so the default applies at its path and one stale value doesn't break
274
+ the Owner's page ([ADR-0001](./docs/adr/0001-overrides-only-ignored-when-invalid.md)):
275
+
276
+ - On read, every Override at a path a validation issue names (or under it) is ignored, and so is every stored key the
277
+ schema no longer knows. Ignoring Overrides can reveal more issues, such as a refinement the validator skipped
278
+ while the shape had issues, so the settings are validated again until they pass or no issue names an Override left
279
+ to ignore. The rest of the Owner's Overrides still apply.
280
+ - `onInvalid(owner, issues)` is called with the issues naming what was ignored; an unknown key is reported as an issue
281
+ with the message `"Unknown setting"`. Without `onInvalid`, they are ignored silently, so pass it to log them.
282
+ - A read never changes the stored document. The next successful write for that Owner drops the ignored Overrides.
283
+ - Writes read the Overrides first, so `onInvalid` can also be called during `set`, `update` or `reset(path)`, and more
284
+ than once for one write when it retries after a conflict.
285
+ - If the Effective settings still fail the schema after the invalid Overrides are ignored, which means the defaults
286
+ themselves no longer fit, the read throws `SettingsDefinitionError`. A write still works from the Overrides it
287
+ can keep and validates only the result of its change, so a write that repairs the settings, such as setting the
288
+ value a refinement asks for, succeeds; one that doesn't throws `SettingsValidationError`.
289
+
290
+ Changing a schema in a way that invalidates stored values is a product decision: those Owners go back to the default.
291
+
292
+ ## Concurrent writes
293
+
294
+ Each write reads the Owner's Overrides and their version, applies its change and stores the result only if the version
295
+ is unchanged. If another write got there first, it reads again and applies its change to the newer Overrides, so two
296
+ requests changing different settings of one Owner both take effect. After `maxRetries` retries that all lost the race,
297
+ it throws `SettingsConflictError`, whose `owner` names the Owner, and stores nothing; it is safe to try the whole write
298
+ again later. `reset()` deletes without a version check. `MemoryStore` keeps an Owner's versions increasing across
299
+ `reset()`, so a write that read the Owner before the reset is a conflict; `PrismaStore` can't, see
300
+ [Storing settings with Prisma](#storing-settings-with-prisma).
301
+
302
+ ## Secret settings
303
+
304
+ The package doesn't encrypt anything. Keep a secret setting, such as an SMTP password, sealed with
305
+ [`@rezwanul7/secret-vault`](../secret-vault/README.md): seal it before `set()`, store the Sealed value as a string,
306
+ and open it after `get()`. Give the Context the Owner, so a Sealed value copied to another Owner doesn't open.
307
+
308
+ ```ts
309
+ import {
310
+ createSettings,
311
+ defineSettings,
312
+ type Owner,
313
+ } from "@rezwanul7/settings";
314
+ import { PrismaStore } from "@rezwanul7/settings/prisma";
315
+ import { z } from "zod";
316
+ import { prisma } from "./db";
317
+ import { vault } from "./vault";
318
+
319
+ const organization = defineSettings({
320
+ schema: z.object({
321
+ smtp: z.object({
322
+ host: z.string(),
323
+ // A Sealed value made by the Vault, never the password itself.
324
+ password: z.string().startsWith("vault:").nullable(),
325
+ }),
326
+ }),
327
+ defaults: { smtp: { host: "localhost", password: null } },
328
+ });
329
+
330
+ const settings = createSettings({
331
+ definitions: { organization },
332
+ store: new PrismaStore({ delegate: prisma.setting }),
333
+ });
334
+
335
+ function smtpPasswordContext(owner: Owner): string {
336
+ return `settings:smtp.password:${owner.scope}:${owner.id}`;
337
+ }
338
+
339
+ export async function saveSmtpPassword(orgId: string, password: string) {
340
+ const org = settings.scope("organization", orgId);
341
+ const sealed = await vault.encrypt(password, smtpPasswordContext(org.owner));
342
+ await org.set("smtp.password", sealed);
343
+ }
344
+
345
+ export async function readSmtpPassword(orgId: string): Promise<string | null> {
346
+ const org = settings.scope("organization", orgId);
347
+ const sealed = await org.get("smtp.password");
348
+ return sealed === null
349
+ ? null
350
+ : vault.decrypt(sealed, smtpPasswordContext(org.owner));
351
+ }
352
+ ```
353
+
354
+ The Effective settings then hold only the Sealed value, so they can be logged or passed around without exposing the
355
+ secret; open it on the server, only where it is used.
356
+
357
+ ## Errors
358
+
359
+ - `SettingsValidationError`: a write would make the Effective settings fail the schema. Nothing is stored. Its
360
+ `issues` are the Standard Schema issues, with paths into the settings, for showing next to form fields.
361
+ - `SettingsConflictError`: a write lost the version race on every try, `maxRetries` retries included. Nothing is
362
+ stored, and the write can be tried again. Its `owner` names the Owner.
363
+ - `SettingsDefinitionError`: a Scope name isn't in `definitions`, a Scope's defaults fail its schema, or an Owner's
364
+ Effective settings still fail it after ignoring stale Overrides. It is a bug in the app's Settings definitions, not
365
+ in the input, so don't show it to a user. It carries the `scope` and, for a failed validation, the `issues`.
366
+
367
+ `createSettings` also throws a `RangeError` for an invalid `maxRetries`, and `scope()` throws a `TypeError` for an id
368
+ passed to a single-owner Scope or missing for any other, which the types already rule out.
369
+
370
+ ```ts
371
+ import { SettingsValidationError } from "@rezwanul7/settings";
372
+
373
+ try {
374
+ await settings.scope("user", session.user.id).update(formValues);
375
+ } catch (error) {
376
+ if (error instanceof SettingsValidationError) {
377
+ return { fieldErrors: error.issues };
378
+ }
379
+ throw error;
380
+ }
381
+ ```
382
+
383
+ ## API reference
384
+
385
+ Every export, parameter, and default is listed in the [API reference](./docs/api/README.md), generated from the source.
386
+
387
+ ## License
388
+
389
+ MIT License. See [LICENSE](./LICENSE) for details.