@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/dist/index.d.ts CHANGED
@@ -76,11 +76,43 @@ interface SettingsDefinition<T = unknown, SingleOwner extends boolean = boolean>
76
76
  /** Whether the Scope has exactly one Owner, stored with the id `"default"`. */
77
77
  readonly singleOwner: SingleOwner;
78
78
  }
79
+ /** The settings type of a Settings definition. Internal: not exported from the package entry. */
80
+ type SettingsOf<D> = D extends SettingsDefinition<infer T> ? T : never;
81
+ /** Counts down the depth {@link NonJsonPaths} still walks, so a recursive settings type can't recurse forever. */
82
+ type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
83
+ /** Whether an object type has a method, as a `Date`, `Map`, `Set` or class instance does. */
84
+ type HasMethod<T> = true extends {
85
+ [K in keyof T]-?: 0 extends 1 & T[K] ? false : T[K] extends (...args: never[]) => unknown ? true : false;
86
+ }[keyof T] ? true : false;
87
+ /**
88
+ * The dot paths in `T` whose values can't be kept as JSON, such as a `Date`, `Map`, `Set`, `bigint` or class
89
+ * instance; `never` when every value can. An array's elements are named `path[]`. `unknown` and `any` pass, since
90
+ * the runtime check covers them, and so does anything deeper than the walk goes.
91
+ */
92
+ type NonJsonPaths<T, P extends string, D extends number = 10> = [D] extends [
93
+ never
94
+ ] ? never : 0 extends 1 & T ? never : unknown extends T ? never : T extends string | number | boolean | null | undefined ? never : T extends bigint | symbol | ((...args: never[]) => unknown) ? P : T extends readonly (infer E)[] ? NonJsonPaths<E, `${P}[]`, Depth[D]> : T extends object ? HasMethod<T> extends true ? P : {
95
+ [K in keyof T & string]-?: NonJsonPaths<T[K], P extends "" ? K : `${P}.${K}`, Depth[D]>;
96
+ }[keyof T & string] : P;
97
+ /**
98
+ * Nothing extra when the schema's output can be kept as JSON. Otherwise a property no schema has, whose name states
99
+ * the rule and whose type names the offending paths, so the compile error at `schema` reads as the reason.
100
+ */
101
+ type JsonOutputCheck<Schema extends StandardSchemaV1> = [
102
+ NonJsonPaths<StandardSchemaV1.InferOutput<Schema>, "">
103
+ ] extends [never] ? unknown : {
104
+ readonly "Not a JSON value, so it can't be stored at": NonJsonPaths<StandardSchemaV1.InferOutput<Schema>, ""> extends "" ? "(the whole settings)" : NonJsonPaths<StandardSchemaV1.InferOutput<Schema>, "">;
105
+ };
79
106
  /**
80
107
  * Declares a Scope's Settings definition. The settings type is inferred from the schema's output type, and
81
108
  * `defaults` must be a complete value of it; optional schema fields may be left out. Any Standard Schema works, such
82
109
  * as one from Zod, Valibot or ArkType.
83
110
  *
111
+ * Overrides are kept as JSON, so a schema whose output has a value that isn't, such as a `Date` from
112
+ * `z.coerce.date()`, a `Map`, a `Set`, a `bigint` or a class instance, is a compile error at `schema` that names the
113
+ * path. Store a date as its string and convert it where the setting is used. Output typed `unknown` or `any` passes;
114
+ * writes still check their values at runtime.
115
+ *
84
116
  * The defaults are checked against the schema on the first operation on the Scope, because Standard Schema
85
117
  * validation may be async; `createSettings` throws `SettingsDefinitionError` there if they fail.
86
118
  *
@@ -88,27 +120,27 @@ interface SettingsDefinition<T = unknown, SingleOwner extends boolean = boolean>
88
120
  * @returns The Settings definition, to pass to `createSettings` under its Scope name.
89
121
  *
90
122
  * @example
91
- * const user = defineSettings({
123
+ * const userDefinition = defineSettings({
92
124
  * schema: z.object({ theme: z.enum(["light", "dark"]), language: z.string() }),
93
125
  * defaults: { theme: "light", language: "en" },
94
126
  * });
95
- * const global = defineSettings({
127
+ * const globalDefinition = defineSettings({
96
128
  * schema: z.object({ maintenanceBanner: z.string().nullable() }),
97
129
  * defaults: { maintenanceBanner: null },
98
130
  * singleOwner: true,
99
131
  * });
100
132
  */
101
133
  declare function defineSettings<Schema extends StandardSchemaV1, SingleOwner extends boolean = false>(definition: {
102
- /** The Standard Schema every Owner's Effective settings must pass. */
103
- readonly schema: Schema;
134
+ /** The Standard Schema every Owner's Effective settings must pass. Its output must be JSON. */
135
+ readonly schema: Schema & JsonOutputCheck<Schema>;
104
136
  /** The complete settings used where an Owner has no Override. */
105
137
  readonly defaults: StandardSchemaV1.InferOutput<Schema>;
106
138
  /** `true` for a Scope with exactly one Owner, which takes no id. Defaults to `false`. */
107
139
  readonly singleOwner?: SingleOwner;
108
140
  }): SettingsDefinition<StandardSchemaV1.InferOutput<Schema>, SingleOwner>;
109
141
 
110
- /** Objects whose keys a dot path walks through; arrays, functions and dates are values, never walked into. */
111
- type Walkable<T> = T extends readonly unknown[] | Date | ((...args: never[]) => unknown) ? never : T extends object ? T : never;
142
+ /** Objects whose keys a dot path walks through; arrays and functions are values, never walked into. */
143
+ type Walkable<T> = T extends readonly unknown[] | ((...args: never[]) => unknown) ? never : T extends object ? T : never;
112
144
  /**
113
145
  * The union of dot paths through the nested plain objects of `T`, such as `"theme" | "notifications" |
114
146
  * "notifications.email"`. A path never descends into an array: an array is one value, set and reset as a whole.
@@ -128,11 +160,11 @@ type Path<T> = [Walkable<T>] extends [never] ? never : {
128
160
  type PathValue<T, P extends string> = P extends keyof Walkable<T> ? Walkable<T>[P] : P extends `${infer K}.${infer Rest}` ? K extends keyof Walkable<T> ? PathValue<NonNullable<Walkable<T>[K]>, Rest> | (null extends Walkable<T>[K] ? undefined : never) | (undefined extends Walkable<T>[K] ? undefined : never) : never : never;
129
161
  /**
130
162
  * A partial of `T` at any depth, for `update`: every key of a nested plain object may be left out or `undefined`.
131
- * Arrays, dates and functions are whole values, never partial.
163
+ * Arrays and functions are whole values, never partial.
132
164
  *
133
165
  * @typeParam T - The settings type.
134
166
  */
135
- type DeepPartial<T> = T extends readonly unknown[] | Date | ((...args: never[]) => unknown) ? T : T extends object ? {
167
+ type DeepPartial<T> = T extends readonly unknown[] | ((...args: never[]) => unknown) ? T : T extends object ? {
136
168
  [K in keyof T]?: DeepPartial<T[K]> | undefined;
137
169
  } : T;
138
170
 
@@ -152,6 +184,10 @@ interface SettingsOptions<Definitions extends Record<string, SettingsDefinition>
152
184
  * next successful write drops them. Writes read the Overrides first, so it can also be called during a write, again
153
185
  * on each retry. Without it, ignored Overrides are dropped silently.
154
186
  *
187
+ * It isn't awaited, and it can't change the result of the read or write that calls it: if it throws or returns a
188
+ * rejected promise, the operation goes on as if it had succeeded, and the failure goes to `console.warn` the first
189
+ * time only for each {@link createSettings} result.
190
+ *
155
191
  * @param owner - The Owner whose Overrides were ignored.
156
192
  * @param issues - The Standard Schema issues the Effective settings failed with, naming the ignored paths.
157
193
  */
@@ -259,8 +295,6 @@ interface ScopedSettings<T> {
259
295
  */
260
296
  reset(): Promise<void>;
261
297
  }
262
- /** The settings type of a Settings definition. */
263
- type SettingsOf<D> = D extends SettingsDefinition<infer T> ? T : never;
264
298
  /** The id argument of `scope()`: none for a single-owner Scope, a string for any other. */
265
299
  type OwnerIdArgs<D> = D extends SettingsDefinition<unknown, true> ? [] : [id: string];
266
300
  /**
@@ -279,7 +313,10 @@ type OwnerIdArgs<D> = D extends SettingsDefinition<unknown, true> ? [] : [id: st
279
313
  * @throws `RangeError` If `maxRetries` isn't a whole number, 0 or more.
280
314
  *
281
315
  * @example
282
- * const settings = createSettings({ definitions: { user, global }, store: new MemoryStore() });
316
+ * const settings = createSettings({
317
+ * definitions: { user: userDefinition, global: globalDefinition },
318
+ * store: new MemoryStore(),
319
+ * });
283
320
  * const theme = await settings.scope("user", "1").get("theme");
284
321
  * await settings.scope("global").set("maintenanceBanner", "Back at 10:00");
285
322
  */
@@ -288,8 +325,11 @@ declare function createSettings<Definitions extends Record<string, SettingsDefin
288
325
  * Returns the settings of one Owner of the Scope `name`.
289
326
  *
290
327
  * @param name - The Scope name, a key of `definitions`.
291
- * @param id - The Owner's id; left out for a single-owner Scope.
328
+ * @param id - The Owner's id, any non-empty string; left out for a single-owner Scope. The package doesn't interpret
329
+ * it, so ids that differ in any way, such as `"1"` and `" 1"`, are different Owners.
292
330
  * @throws {@link SettingsDefinitionError} If `name` isn't a key of `definitions`.
331
+ * @throws `TypeError` If `id` is the empty string, is missing for a normal Scope, or is given for a single-owner
332
+ * Scope.
293
333
  */
294
334
  scope<Name extends keyof Definitions & string>(name: Name, ...id: OwnerIdArgs<Definitions[Name]>): ScopedSettings<SettingsOf<Definitions[Name]>>;
295
335
  };
@@ -302,7 +342,7 @@ declare function createSettings<Definitions extends Record<string, SettingsDefin
302
342
  * write that read a version before a delete is a conflict; the Store remembers one number per Owner it ever stored.
303
343
  *
304
344
  * @example
305
- * const settings = createSettings({ definitions: { user }, store: new MemoryStore() });
345
+ * const settings = createSettings({ definitions: { user: userDefinition }, store: new MemoryStore() });
306
346
  */
307
347
  declare class MemoryStore implements SettingsStore {
308
348
  #private;
@@ -314,6 +354,230 @@ declare class MemoryStore implements SettingsStore {
314
354
  delete(owner: Owner): Promise<void>;
315
355
  }
316
356
 
357
+ /**
358
+ * The part of a Secret Vault `Vault` that {@link SealedStore} uses, so this package doesn't depend on Secret Vault.
359
+ * A `Vault` fits.
360
+ */
361
+ interface SealingVault {
362
+ /**
363
+ * Seals a JSON value.
364
+ *
365
+ * @param value - The value to seal.
366
+ * @param context - Where the Sealed value is stored.
367
+ * @returns The Sealed value.
368
+ */
369
+ encryptJson(value: unknown, context: string): Promise<string>;
370
+ /**
371
+ * Opens a Sealed value made by `encryptJson`; rejects when it doesn't open.
372
+ *
373
+ * @param sealed - The Sealed value.
374
+ * @param context - The Context it was sealed under.
375
+ * @returns The Plaintext value.
376
+ */
377
+ decryptJson(sealed: string, context: string): Promise<unknown>;
378
+ /**
379
+ * Whether a stored value is a Sealed value rather than plain text still stored at a listed path. Optional: a Secret
380
+ * Vault `Vault` has `isSealed` only as a static method, so without it `SealedStore` treats a string starting with
381
+ * `vault:` as sealed.
382
+ *
383
+ * @param value - A stored value.
384
+ */
385
+ isSealed?(value: unknown): boolean;
386
+ }
387
+ /**
388
+ * The paths to keep sealed, by Scope name, each checked against its Scope's settings type, such as
389
+ * `{ organization: ["smtp.password"] }`. A Scope may be left out.
390
+ *
391
+ * @typeParam Definitions - The Settings definitions by Scope name.
392
+ */
393
+ type SealedPaths<Definitions extends Record<string, SettingsDefinition>> = {
394
+ readonly [Scope in keyof Definitions & string]?: readonly Path<SettingsOf<Definitions[Scope]>>[];
395
+ };
396
+ /**
397
+ * Options of {@link SealedStore}.
398
+ *
399
+ * @typeParam Definitions - The Settings definitions by Scope name, the same ones passed to `createSettings`.
400
+ */
401
+ interface SealedStoreOptions<Definitions extends Record<string, SettingsDefinition>> {
402
+ /** The Store that keeps the Sealed values. */
403
+ readonly store: SettingsStore;
404
+ /** Seals and opens values; a Secret Vault `Vault` fits. */
405
+ readonly vault: SealingVault;
406
+ /**
407
+ * Each Scope's Settings definition, by Scope name: the same object passed to `createSettings`. Only used to check
408
+ * `paths`.
409
+ */
410
+ readonly definitions: Definitions;
411
+ /**
412
+ * Dot paths into the settings to keep sealed, by Scope name, such as `{ organization: ["smtp.password"] }`. A path
413
+ * may name an object, which is sealed whole as JSON. No path may be inside another of the same Scope; two Scopes may
414
+ * list the same path. A Scope left out has nothing sealed: a Sealed value stored there reads back as its raw
415
+ * `vault:…` string.
416
+ */
417
+ readonly paths: NoInfer<SealedPaths<Definitions>>;
418
+ }
419
+ /**
420
+ * A Store that keeps the Overrides at the listed paths as Sealed values in the Store it wraps, and hands the core
421
+ * their Plaintext. The schema validates the Plaintext, and the app calls `set` and `get` with plain values.
422
+ *
423
+ * Every write seals again with the Vault's current secret, which finishes a Rotation for that Owner. A Sealed value
424
+ * that doesn't open makes the read throw: it is never ignored as a stale Override, because the next write would
425
+ * replace the secret with its default. A plain value at a listed path reads as it is and is sealed on the Owner's next
426
+ * write. Values at unlisted paths are never opened.
427
+ *
428
+ * Paths are listed per Scope and checked against each Scope's settings type, so a misspelled path doesn't compile. Only
429
+ * the paths listed for an Owner's Scope are sealed and opened; the same path in another Scope stays plain.
430
+ *
431
+ * Nest it outermost, around a {@link CachedStore}, so the cache holds only Sealed values.
432
+ *
433
+ * @typeParam Definitions - The Settings definitions by Scope name.
434
+ *
435
+ * @example
436
+ * const store = new SealedStore({
437
+ * store: new PrismaStore({ delegate: prisma.setting }),
438
+ * vault,
439
+ * definitions: { user, organization },
440
+ * paths: { organization: ["smtp.password"] },
441
+ * });
442
+ */
443
+ declare class SealedStore<Definitions extends Record<string, SettingsDefinition> = Record<string, SettingsDefinition>> implements SettingsStore {
444
+ #private;
445
+ /**
446
+ * @param options - The Store to wrap, the Vault, the Settings definitions and the sealed paths by Scope.
447
+ * @throws `TypeError` when no Scope lists a path, a Scope in `paths` isn't in `definitions`, a path is empty, or a
448
+ * path is inside another of the same Scope.
449
+ */
450
+ constructor(options: SealedStoreOptions<Definitions>);
451
+ /** {@inheritDoc SettingsStore.read} */
452
+ read(owner: Owner): Promise<StoredOverrides | null>;
453
+ /** {@inheritDoc SettingsStore.write} */
454
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
455
+ /**
456
+ * Removes one Sealed value that won't open, so the Owner can read its settings and set a new secret again; its
457
+ * other Sealed values stay sealed and aren't opened, and a plain value at another listed path is sealed, as on any
458
+ * write. Its other Overrides stay as they are. A value that opens, a
459
+ * plain value and an empty path are left alone, so running it again after the Owner set a new secret changes
460
+ * nothing. Any rejection from `decryptJson` counts as not opening.
461
+ *
462
+ * **Check the Vault secret first.** With a wrong or missing secret every Sealed value fails to open, so `discard`
463
+ * would remove secrets that fixing the configuration would have recovered.
464
+ *
465
+ * It reads the wrapped Store and writes with the version it read. On a conflict it reads again and retries, up to 3
466
+ * times; a {@link CachedStore} underneath evicts on the conflict, so the retry reads fresh Overrides.
467
+ *
468
+ * @param owner - The Owner whose Sealed value to remove.
469
+ * @param path - A path listed for the Owner's Scope, such as `"smtp.password"`.
470
+ * @returns `true` when a Sealed value that won't open was removed, `false` when nothing was changed.
471
+ * @throws `TypeError` when `path` isn't listed for the Owner's Scope.
472
+ * @throws {@link SettingsConflictError} when every write lost the version race to another write.
473
+ */
474
+ discard(owner: Owner, path: string): Promise<boolean>;
475
+ /** {@inheritDoc SettingsStore.delete} */
476
+ delete(owner: Owner): Promise<void>;
477
+ }
478
+
479
+ /**
480
+ * A cache of strings with a time to live, which {@link CachedStore} keeps Overrides in. {@link MemoryCache} fits;
481
+ * so does a thin wrapper around Redis or Keyv.
482
+ */
483
+ interface SettingsCache {
484
+ /**
485
+ * Reads an entry.
486
+ *
487
+ * @param key - The cache key.
488
+ * @returns The value, or `undefined` when there is none or it expired.
489
+ */
490
+ get(key: string): Promise<string | undefined>;
491
+ /**
492
+ * Stores an entry, replacing any under the key.
493
+ *
494
+ * @param key - The cache key.
495
+ * @param value - The string to keep.
496
+ * @param ttlSeconds - Seconds until the entry expires.
497
+ */
498
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
499
+ /**
500
+ * Removes an entry; removing a missing one does nothing.
501
+ *
502
+ * @param key - The cache key.
503
+ */
504
+ delete(key: string): Promise<void>;
505
+ }
506
+ /** Options of {@link CachedStore}. */
507
+ interface CachedStoreOptions {
508
+ /** The Store to read through. */
509
+ readonly store: SettingsStore;
510
+ /** Where to keep what the Store returns. */
511
+ readonly cache: SettingsCache;
512
+ /** Seconds an entry stays cached. */
513
+ readonly ttl: number;
514
+ /** Starts every cache key. Defaults to `"settings"`. */
515
+ readonly prefix?: string;
516
+ /**
517
+ * Called with an error a cache method threw; the Store then answers instead.
518
+ *
519
+ * It isn't awaited, and it can't change the result of the read, write or delete that calls it: if it throws or
520
+ * returns a rejected promise, the operation goes on as if it had succeeded, so a write still returns the wrapped
521
+ * Store's result. The failure goes to `console.warn` the first time only for each {@link CachedStore}.
522
+ */
523
+ readonly onError?: (error: unknown) => void;
524
+ }
525
+ /**
526
+ * A Store that keeps what the Store it wraps returns in a cache for `ttl` seconds, an Owner with nothing stored
527
+ * included. Every write, conflict and delete evicts the Owner, so a conflicting write retries against fresh Overrides,
528
+ * and a write on this instance is read back at once by this instance, even when one of its reads of that Owner was in
529
+ * flight during the write: a read that an eviction overtook returns what it read but doesn't cache it. A cache that
530
+ * throws is reported to `onError` and the Store answers instead.
531
+ *
532
+ * With a per-process cache and several instances, another instance can read Overrides up to `ttl` seconds old. Share
533
+ * a cache, such as Redis, to avoid most of it. Even then, a read on another instance that was in flight during the
534
+ * write can still cache the old Overrides, which are then read for up to `ttl` seconds.
535
+ *
536
+ * @example
537
+ * const store = new CachedStore({ store: prismaStore, cache: new MemoryCache(), ttl: 60 });
538
+ */
539
+ declare class CachedStore implements SettingsStore {
540
+ #private;
541
+ /**
542
+ * @param options - The Store to wrap, the cache and the TTL.
543
+ * @throws `RangeError` when `ttl` isn't a positive number.
544
+ */
545
+ constructor(options: CachedStoreOptions);
546
+ /** {@inheritDoc SettingsStore.read} */
547
+ read(owner: Owner): Promise<StoredOverrides | null>;
548
+ /** {@inheritDoc SettingsStore.write} */
549
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
550
+ /** {@inheritDoc SettingsStore.delete} */
551
+ delete(owner: Owner): Promise<void>;
552
+ }
553
+
554
+ /** Options of {@link MemoryCache}. */
555
+ interface MemoryCacheOptions {
556
+ /** Most entries kept; the oldest are evicted past it. Defaults to 1000. */
557
+ readonly maxEntries?: number;
558
+ }
559
+ /**
560
+ * A {@link SettingsCache} in process memory. Entries expire after their TTL and the oldest are evicted past
561
+ * `maxEntries`. It is per process: instances don't share it.
562
+ *
563
+ * @example
564
+ * const cache = new MemoryCache({ maxEntries: 5000 });
565
+ */
566
+ declare class MemoryCache implements SettingsCache {
567
+ #private;
568
+ /**
569
+ * @param options - The size limit.
570
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
571
+ */
572
+ constructor(options?: MemoryCacheOptions);
573
+ /** {@inheritDoc SettingsCache.get} */
574
+ get(key: string): Promise<string | undefined>;
575
+ /** {@inheritDoc SettingsCache.set} */
576
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
577
+ /** {@inheritDoc SettingsCache.delete} */
578
+ delete(key: string): Promise<void>;
579
+ }
580
+
317
581
  /**
318
582
  * A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
319
583
  * Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
@@ -358,4 +622,4 @@ declare class SettingsDefinitionError extends Error {
358
622
  constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
359
623
  }
360
624
 
361
- export { MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
625
+ export { CachedStore, type CachedStoreOptions, MemoryCache, type MemoryCacheOptions, MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, type SealedPaths, SealedStore, type SealedStoreOptions, type SealingVault, type SettingsCache, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };