@effected/app 0.9.1 → 0.10.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/App.js CHANGED
@@ -53,6 +53,12 @@ var App = class {
53
53
  * reuse that binding. Calling it inline at two provide sites opens two
54
54
  * databases — two connections onto one file, two migration ledgers, and two
55
55
  * independent `CacheEvent` PubSubs whose subscribers each see half the events.
56
+ *
57
+ * Testing cache expiry against this layer has one ordering rule: provide
58
+ * `TestClock.layer()` **outside** the `Effect.provide` that supplies this
59
+ * layer, never beneath it. Underneath, the test body has no `TestClock` in
60
+ * its own context and `TestClock.adjust` dies as a defect — so the entries
61
+ * under test carry real timestamps and nothing ever expires.
56
62
  */
57
63
  static layer = layer;
58
64
  /**
package/AppConfig.js CHANGED
@@ -8,10 +8,14 @@ const layer = (tag, options) => Layer.unwrap(Effect.gen(function* () {
8
8
  const invalid = badFilename("AppConfig.layer", options.filename);
9
9
  if (invalid !== void 0) return yield* Effect.die(invalid);
10
10
  const appDirs = yield* AppDirs;
11
- const resolvers = options.native === false ? [XdgConfig.resolver({ filename: options.filename })] : [XdgConfig.resolver({ filename: options.filename }), XdgConfig.nativeResolver({
12
- namespace: appDirs.namespace,
13
- filename: options.filename
14
- })];
11
+ const resolvers = [
12
+ ...options.resolvers ?? [],
13
+ XdgConfig.resolver({ filename: options.filename }),
14
+ ...options.native === false ? [] : [XdgConfig.nativeResolver({
15
+ namespace: appDirs.namespace,
16
+ filename: options.filename
17
+ })]
18
+ ];
15
19
  return ConfigFile.layer(tag, {
16
20
  schema: options.schema,
17
21
  codec: options.codec,
@@ -19,6 +23,7 @@ const layer = (tag, options) => Layer.unwrap(Effect.gen(function* () {
19
23
  resolvers,
20
24
  defaultPath: XdgConfig.savePath(options.filename),
21
25
  ...options.validate !== void 0 && { validate: options.validate },
26
+ ...options.parseOptions !== void 0 && { parseOptions: options.parseOptions },
22
27
  ...options.events !== void 0 && { events: options.events }
23
28
  });
24
29
  }));
@@ -47,6 +52,15 @@ var AppConfig = class {
47
52
  * config-file's infallible `defaultPath` slot without an `orDie` because xdg
48
53
  * resolves at layer-construction time.
49
54
  *
55
+ * `options.resolvers` prepends to that chain, which covers the case this
56
+ * preset otherwise could not: a CLI whose `--config` flag must outrank the
57
+ * app's XDG search path. What it deliberately does not cover is a chain that
58
+ * needs the XDG resolvers somewhere other than last, or no XDG resolvers at
59
+ * all — an app wanting that composes `ConfigFile.layer` from
60
+ * `@effected/config-file` directly and orders the whole chain itself, which
61
+ * costs it only the `defaultPath` and ambient-namespace wiring this preset
62
+ * does for free.
63
+ *
50
64
  * **The namespace is never a parameter.** It is read from the ambient
51
65
  * `AppDirs` service at layer build time, so it is typed exactly once, in
52
66
  * `App.layer` — the two-strings drift where an app passes `"myapp"` to
package/README.md CHANGED
@@ -121,6 +121,59 @@ What it does take is a **codec, required**, never inferred from the filename's e
121
121
 
122
122
  `AppConfig` lives in its own module and reaches `@effected/xdg` and `@effected/config-file` **only** — never `@effected/store`. An application that wants XDG-placed config files and no database imports `AppConfig` alone, and no SQLite driver enters its graph. `App`, `AppStore` and `AppCache` are the exports that reach a database, and keeping the two graphs apart is why there is no `App = { … }` namespace object here.
123
123
 
124
+ ## A `--config` flag, without leaving the preset
125
+
126
+ Most CLIs have one, and it has to outrank the app's own search path. Pass `resolvers` and it does:
127
+
128
+ ```ts
129
+ import { AppConfig } from "@effected/app";
130
+ import { ConfigResolver, TomlCodec } from "@effected/config-file";
131
+
132
+ const ConfigLive = AppConfig.layer(SettingsFile, {
133
+ filename: "settings.toml",
134
+ schema: Settings,
135
+ codec: TomlCodec,
136
+ // Composed AHEAD of the XDG chain, in the order given.
137
+ resolvers: flag === undefined ? [] : [ConfigResolver.explicitPath(flag)],
138
+ });
139
+ ```
140
+
141
+ The XDG search path and the native probe stay behind whatever you prepend, so absent the flag nothing changes. `ConfigResolver.staticDir` covers a `--config` naming a directory, and `ConfigResolver.upwardWalk` a project-local file found by walking up from the cwd.
142
+
143
+ Two properties to be deliberate about. **A resolver that finds nothing falls through**: every `ConfigResolver`'s error channel is `never` by contract, so a `--config` pointing at a file that does not exist quietly loads the XDG config instead. If that must be an error, check the path before you build the layer — discovery cannot make that distinction for you. And **the save path is unaffected**: `save` still writes to the app's own config directory, so writing back to the file a flag named is `write(value, path)`, which takes the path explicitly.
144
+
145
+ ### Wiring the flag to the layer
146
+
147
+ A layer is built before a CLI parses anything, so the parsed `--config` has to reach `AppConfig.layer` somehow. `effect/unstable/cli` has two ways, and neither needs an `Effect.provide` inside the handler.
148
+
149
+ For a single command, `Command.provide` takes **a function of the parsed input**, not just a finished layer:
150
+
151
+ ```ts
152
+ Command.make("validate", { config: Flag.string("config").pipe(Flag.optional) }, () =>
153
+ Effect.gen(function* () {
154
+ const settings = yield* (yield* SettingsFile).load; // just requires the service
155
+ }),
156
+ ).pipe(Command.provide(({ config }) => makeConfigLive(Option.getOrUndefined(config))));
157
+ ```
158
+
159
+ For several subcommands sharing one `--config`, make the flag a **global setting** — `GlobalFlag.setting` returns a `Context.Service`, so the parsed value can sit in the layer's `R` and the layer attaches once at the root:
160
+
161
+ ```ts
162
+ const ConfigFlag = GlobalFlag.setting("config")({ flag: Flag.string("config").pipe(Flag.optional) });
163
+
164
+ const ConfigLive = Layer.unwrap(Effect.map(ConfigFlag, (f) => makeConfigLive(Option.getOrUndefined(f))));
165
+
166
+ const root = Command.make("myapp", {}, () => Effect.void).pipe(
167
+ Command.withSubcommands([validate, sync]),
168
+ Command.provide(ConfigLive), // one call site, every subcommand
169
+ Command.withGlobalFlags([ConfigFlag]),
170
+ );
171
+ ```
172
+
173
+ The flag is then accepted on either side of the subcommand name.
174
+
175
+ A chain that needs the XDG resolvers somewhere other than last — or not at all — has outgrown the preset. Compose `ConfigFile.layer` from [`@effected/config-file`](../config-file) directly and order the whole chain yourself; it costs you only the `defaultPath` and ambient-namespace wiring this preset does for free.
176
+
124
177
  Its `native` option defaults to **`true`** — the opposite of `AppDirsOptions.native`, and the asymmetry is deliberate. *Creating* a native directory commits an application to a location, so it is opt-in; *probing* one for a config file the user already put there costs a `stat` that finds nothing, so it is opt-out. Reading `~/Library/Application Support` is a courtesy; writing there uninvited is not.
125
178
 
126
179
  ## Ensure before open
package/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Cache, CacheError, CacheOptions, Store, StoreError, StoreMigrationError, StoreOptions } from "@effected/store";
2
2
  import { AppDirs, AppDirsError, AppDirsOptions, Xdg, XdgEnvError, XdgPaths } from "@effected/xdg";
3
- import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
4
- import { ConfigCodec, ConfigEvents, ConfigEventsShape, ConfigFileShape, ConfigValidationError, MergeStrategy } from "@effected/config-file";
3
+ import { Context, Effect, FileSystem, Layer, Path, Schema, SchemaAST } from "effect";
4
+ import { ConfigCodec, ConfigEvents, ConfigEventsShape, ConfigFileShape, ConfigResolver, ConfigValidationError, MergeStrategy } from "@effected/config-file";
5
5
  //#region src/AppCache.d.ts
6
6
  /**
7
7
  * Options for {@link AppCache.layer}.
@@ -162,6 +162,12 @@ declare class App {
162
162
  * reuse that binding. Calling it inline at two provide sites opens two
163
163
  * databases — two connections onto one file, two migration ledgers, and two
164
164
  * independent `CacheEvent` PubSubs whose subscribers each see half the events.
165
+ *
166
+ * Testing cache expiry against this layer has one ordering rule: provide
167
+ * `TestClock.layer()` **outside** the `Effect.provide` that supplies this
168
+ * layer, never beneath it. Underneath, the test body has no `TestClock` in
169
+ * its own context and `TestClock.adjust` dies as a defect — so the entries
170
+ * under test carry real timestamps and nothing ever expires.
165
171
  */
166
172
  static readonly layer: (options: AppOptions) => Layer.Layer<Xdg | AppDirs | Store | Cache, AppError, FileSystem.FileSystem | Path.Path>;
167
173
  /**
@@ -187,9 +193,14 @@ declare class App {
187
193
  /**
188
194
  * Options for {@link AppConfig.layer}.
189
195
  *
196
+ * @remarks
197
+ * `RR` is the requirements of any caller-supplied `resolvers`; it defaults to
198
+ * `never`, so a chain of built-in resolvers — whose requirements are already in
199
+ * this layer's `R` — never has to be named.
200
+ *
190
201
  * @public
191
202
  */
192
- interface AppConfigOptions<A, I> {
203
+ interface AppConfigOptions<A, I, RR = never> {
193
204
  /**
194
205
  * The config file's name within the app's config directory.
195
206
  *
@@ -216,8 +227,64 @@ interface AppConfigOptions<A, I> {
216
227
  readonly strategy?: MergeStrategy<A>;
217
228
  /** An optional caller-supplied check run after schema decoding. */
218
229
  readonly validate?: (value: A) => Effect.Effect<A, ConfigValidationError>;
230
+ /**
231
+ * Parse options threaded into every schema decode, chiefly
232
+ * `onExcessProperty`.
233
+ *
234
+ * @remarks
235
+ * Defaults to core's `"ignore"`, so a config file's unknown keys are dropped
236
+ * silently — which means a typo'd section cannot be reported, and a field
237
+ * this schema deliberately removed cannot be flagged in a user's older file.
238
+ * `{ onExcessProperty: "error" }` turns both into a `ConfigValidationError`
239
+ * naming the offending path.
240
+ *
241
+ * `validate` cannot do this: it runs on the decoded value, after the excess
242
+ * keys are gone. Keys covered by a `Schema.StructWithRest` rest are not
243
+ * excess, so a deliberate pass-through section still works under `"error"`.
244
+ *
245
+ * Pair it with `errors: "all"`. Core defaults to `"first"`, which for a
246
+ * *loader* means a file with three typos surfaces one per run — fix,
247
+ * re-run, discover the next. The extra work only happens on a document
248
+ * that is already failing.
249
+ */
250
+ readonly parseOptions?: SchemaAST.ParseOptions;
219
251
  /** The opt-in event hook. Pass the `ConfigEvents` class itself. */
220
252
  readonly events?: Context.Key<ConfigEvents, ConfigEventsShape>;
253
+ /**
254
+ * Resolvers composed **ahead** of the XDG chain, in priority order.
255
+ *
256
+ * @remarks
257
+ * The case this exists for is a CLI's `--config` flag: pass
258
+ * `ConfigResolver.explicitPath` for a file, or `ConfigResolver.staticDir` for
259
+ * a directory, and the flag wins — with the app's XDG search path, and the
260
+ * native probe, still behind it as the fallback. `ConfigResolver.upwardWalk`
261
+ * for a project-local file goes here too.
262
+ *
263
+ * Absent, the chain is exactly what it always was, so the default is
264
+ * unchanged: `XdgConfig.resolver`, then `XdgConfig.nativeResolver`.
265
+ *
266
+ * A layer is built before a CLI parses anything, so getting the parsed flag
267
+ * here is the one wiring question this option raises. `effect/unstable/cli`
268
+ * answers it twice, and neither answer needs an `Effect.provide` inside the
269
+ * handler: `Command.provide` accepts a **function of the parsed input**, and
270
+ * for several subcommands sharing one flag, `GlobalFlag.setting` makes the
271
+ * parsed value a `Context.Service` so the layer can take it in `R` and attach
272
+ * once at the root. The README shows both.
273
+ *
274
+ * Two properties to be deliberate about:
275
+ *
276
+ * - **A resolver that finds nothing falls through.** `explicitPath` resolves
277
+ * `Option.none()` for a path that does not exist — every `ConfigResolver`'s
278
+ * error channel is `never` by contract — so a `--config` pointing at a
279
+ * missing file silently loads the XDG config instead. If the flag must be
280
+ * an error when it names nothing, check the path before building the layer;
281
+ * discovery cannot make that distinction for you.
282
+ * - **The save path is unaffected.** `save` still writes to
283
+ * `XdgConfig.savePath(filename)`, not to whatever a prepended resolver
284
+ * discovered. Loading from `--config` and writing back to that same file is
285
+ * `write(value, path)`, which takes the path explicitly.
286
+ */
287
+ readonly resolvers?: ReadonlyArray<ConfigResolver<RR>>;
221
288
  /**
222
289
  * Probe the OS-native config directory as a fallback. Defaults to `true`.
223
290
  *
@@ -253,6 +320,15 @@ declare class AppConfig {
253
320
  * config-file's infallible `defaultPath` slot without an `orDie` because xdg
254
321
  * resolves at layer-construction time.
255
322
  *
323
+ * `options.resolvers` prepends to that chain, which covers the case this
324
+ * preset otherwise could not: a CLI whose `--config` flag must outrank the
325
+ * app's XDG search path. What it deliberately does not cover is a chain that
326
+ * needs the XDG resolvers somewhere other than last, or no XDG resolvers at
327
+ * all — an app wanting that composes `ConfigFile.layer` from
328
+ * `@effected/config-file` directly and orders the whole chain itself, which
329
+ * costs it only the `defaultPath` and ambient-namespace wiring this preset
330
+ * does for free.
331
+ *
256
332
  * **The namespace is never a parameter.** It is read from the ambient
257
333
  * `AppDirs` service at layer build time, so it is typed exactly once, in
258
334
  * `App.layer` — the two-strings drift where an app passes `"myapp"` to
@@ -261,7 +337,7 @@ declare class AppConfig {
261
337
  * This is a layer-returning function: bind the result to a `const` and reuse
262
338
  * that binding, or two provide sites mint two independent service instances.
263
339
  */
264
- static readonly layer: <Self, A, I>(tag: Context.Key<Self, ConfigFileShape<A>>, options: AppConfigOptions<A, I>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
340
+ static readonly layer: <Self, A, I, RR = never>(tag: Context.Key<Self, ConfigFileShape<A>>, options: AppConfigOptions<A, I, RR>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path | AppDirs | Xdg | RR>;
265
341
  }
266
342
  //#endregion
267
343
  export { App, AppCache, type AppCacheOptions, AppConfig, type AppConfigOptions, type AppError, type AppOptions, AppStore, type AppStoreOptions, type AppTestOptions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/app",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "The application control plane for Effect: one layer wiring XDG-namespaced directories, a migrated SQLite store, a TTL cache and a config file to the same place.",
6
6
  "keywords": [
@@ -39,9 +39,9 @@
39
39
  "./package.json": "./package.json"
40
40
  },
41
41
  "peerDependencies": {
42
- "@effected/config-file": "^0.3.0",
43
- "@effected/store": "^0.2.0",
44
- "@effected/xdg": "^0.2.0",
42
+ "@effected/config-file": "^0.4.0",
43
+ "@effected/store": "^0.3.0",
44
+ "@effected/xdg": "^0.2.1",
45
45
  "effect": "4.0.0-beta.107"
46
46
  },
47
47
  "engines": {