@sorrell/app-settings
v3.0.0
Published
Schema-validated, file-backed application settings for effect applications.
Maintainers
Readme
@sorrell/app-settings
Schema-validated, JSON-file-backed application settings for effect.
The package constructs a typed service from an Effect schema. By default, it selects the native per-user configuration directory for process.platform, and creates the directory tree if needed. Successful changes are written atomically before the in-memory value is committed, and a scoped file watcher publishes valid external changes to the application.
Usage
import { NodeFileSystem, NodePath } from "@effect/platform-node";
import { Effect, Layer, Schema, Stream } from "effect";
import { AppSettings } from "@sorrell/app-settings";
const SettingsSchema = Schema.Struct({
launchAtStartup: Schema.Boolean,
theme: Schema.String
});
const Settings = AppSettings.Make(
SettingsSchema,
{
ApplicationName: "SorrellWm",
Initial:
{
launchAtStartup: false,
theme: "system"
}
}
);
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
const Live = Settings.Layer.pipe(Layer.provide(PlatformLive));
const Program = Effect.gen(function*()
{
const Service = yield* Settings;
const Theme = yield* Service.GetSetting("theme");
yield* Effect.log(`Current theme: ${ Theme }`);
yield* Service.SetSetting("theme", "dark");
const LogSettingsChanged = (Value) => Effect.log("Settings changed", Value);
/* `Changes` immediately emits the current value, then every update made *
* through the service or loaded from an external file edit. */
yield* pipe(
Service.Changes,
Stream.runForEach(LogSettingsChanged),
Effect.forkScoped
);
});
Effect.runPromise(pipe(
Effect.scoped(Program),
Effect.provide(Live)
));The layer requires Effect's FileSystem and Path services. This package was piloted and tested with @effect/platform-node in SorrellWm, but it is expected that other platforms may be used without issue.
Default path
With the default application name SorrellWm and filename settings.json, the path is:
| Platform | Path |
| -------- | --------------------------------------------------------------------------------------- |
| Windows | %APPDATA%\SorrellWm\settings.json |
| macOS | ~/Library/Application Support/SorrellWm/settings.json |
| Linux | $XDG_CONFIG_HOME/SorrellWm/settings.json or ~/.config/SorrellWm/settings.json |
This table describes GetDefaultFilePath's Node-style desktop OS conventions only; it does not apply on React Native/Expo (see below).
ApplicationName and FileName customize the final two components,
const Settings = AppSettings.Make(
SettingsSchema,
{
ApplicationName: "MyApplication",
FileName: "preferences.json"
}
);A completely custom path can be supplied with FilePath,
const Settings = AppSettings.Make(
SettingsSchema,
{
ApplicationName: "MyApplication",
FilePath: "./configuration/settings.json"
}
);Expo / React Native
The module has no top-level Node built-in imports (node:path, node:os, etc.), so it is safe to include in a Metro/Expo bundle. Two things are still the consuming app's own responsibility, since neither is shipped by this package:
Always pass
Options.FilePathexplicitly.GetDefaultFilePathonly implements Node-style desktop OS conventions (%APPDATA%,~/Library/Application Support, XDG) and has no principled way to derive an equivalent for React Native'sPlatform.OS("ios"|"android"|"web"). A typical Expo app derives its path fromexpo-file-systeminstead:import * as FileSystem from "expo-file-system"; import { AppSettings } from "@sorrell/app-settings"; const Settings = AppSettings.Make( SettingsSchema, { ApplicationName: "MyExpoApp", FilePath: `${ FileSystem.documentDirectory }settings.json`, Initial: { /* ... */ } } );Supply your own
FileSystem/PathEffect layer. No@effect/platform-*package exists for React Native/Expo today (there's no separate@sorrell/app-settings-shipped Expo layer either -- this package stays a single, platform-agnostic module, the same way@sorrell/app-settingsitself never depended on@effect/platform-node). An Expo consumer hand-rolls a layer againstexpo-file-system, providing exactly the membersAppSettingsactually calls:FileSystem.FileSystem:exists,makeDirectory({ recursive }),readFileString,makeTempFile({ directory, prefix, suffix }),writeFileString,rename,remove({ force }),watch.Path.Path:resolve,dirname,basename.
expo-file-systemhas no directory-watch API equivalent to Node'sfs.watch. When external edits to a sandboxed app's own settings file aren't a concern, a hand-rolledwatchcan reasonably return an empty/never-emitting stream -- reads on startup and writes viaSet/SetSetting/Updatestill work fully either way.
External state synchronization
Settings are the durable description of desired state. Use a downstream synchronization layer when settings also control state outside the JSON file, such as registering the application to run when the user signs in:
const Settings = AppSettings.Make(
SettingsSchema,
{
ApplicationName: "SorrellWm",
Initial:
{
launchAtStartup: false,
theme: "system"
}
}
);
declare const SynchronizeLaunchAtStartup: (Enabled: boolean) =>
Effect.Effect<void, Error, StartupRegistration>;
const SynchronizationLive = AppSettings.SyncSetting(
Settings,
"launchAtStartup",
SynchronizeLaunchAtStartup
);
const Live = SynchronizationLive.pipe(
Layer.provideMerge(Settings.Layer),
Layer.provide(PlatformLive),
Layer.provide(StartupRegistrationLive)
);SyncSetting immediately applies the current committed value, then applies later changes to that setting. Changes to unrelated settings are ignored using Object.is equality. The synchronizer's Effect requirements are retained by the returned layer, so it may depend on services that themselves use Settings without creating a construction cycle.
Sync(Settings, Reconcile) provides the same behavior for the complete settings value. Failures are logged, and do not terminate monitoring; the committed settings remain the desired state and later changes are still processed. A reconciler can apply its own retry policy with Effect.retry when external failures are transient.
Service API
| Member | Behavior |
| --------------------- | ------------------------------------------------------------------------------- |
| Get | Retrieves the complete current settings value. |
| GetSetting(Key) | Retrieves one setting with its schema-derived type. |
| Set(Value) | Validates, atomically persists, and replaces all settings. |
| SetSetting(Key, Value) | Validates, atomically persists, and replaces one setting. |
| Update(f) | Computes and persists a complete replacement value. |
| Changes | Replays the current value and broadcasts subsequent local and external updates. |
File and failure behavior
- Existing files are parsed as JSON and decoded with the supplied schema when the layer starts
- If the file does not exist,
Initialis persisted. WhenInitialis omitted, an empty object is decoded so defaults defined by the schema can provide the initial settings - Missing parent directories are created recursively before the file is loaded or written
- Writes use a temporary file in the same directory followed by an atomic replacement. The service's in-memory value and
Changesstream are updated only after that replacement succeeds - A failed write returns an
AppSettings.FileErrorand leaves the last committed in-memory value unchanged - Invalid external JSON or schema-invalid external values are logged and ignored. The last valid value remains active, and monitoring continues
- A failed watcher is logged and restarted after
WatchRetryDelay(one second by default)
AppSettings.Make also accepts the following options,
ApplicationNameFileNameFilePathJsonIndentNumWatchDebounceWatchRetryDelay
The returned tag retains the inferred settings type, its .Schema, its resolved .FilePath, and its scoped .Layer.
