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