@effected/app 0.20.0 → 0.21.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
@@ -15,11 +15,11 @@ const testPaths = () => XdgPaths.make({
15
15
  configDirs: ["/etc/xdg"],
16
16
  dataDirs: ["/usr/local/share", "/usr/share"]
17
17
  });
18
+ const layerDirs = (options) => Layer.provideMerge(AppDirs.layer(options), Xdg.layer);
18
19
  const layer = (options) => {
19
20
  const { store, cache, ...dirOptions } = options;
20
- const dirs = Layer.provideMerge(AppDirs.layer(dirOptions), Xdg.layer);
21
21
  const databases = Layer.mergeAll(AppStore.layer(store), AppCache.layer(cache));
22
- return Layer.provideMerge(databases, dirs);
22
+ return Layer.provideMerge(databases, layerDirs(dirOptions));
23
23
  };
24
24
  const layerTest = (options) => {
25
25
  const dirs = Layer.provideMerge(AppDirs.layer({ namespace: options.namespace }), Xdg.layerFrom(options.paths ?? testPaths()));
@@ -44,10 +44,29 @@ var App = class {
44
44
  * so all four services come out and only `FileSystem` and `Path` stay in `R` —
45
45
  * the two the consumer's platform layer supplies once, at the edge.
46
46
  *
47
- * `App.layer` always provides **both** databases: an application that wants
48
- * only one composes `AppStore.layer` or `AppCache.layer` directly and never
49
- * opens the other file. Passing no `cache` options still opens `cache.db`,
50
- * because `CacheOptions` are all-optional and absence means defaults.
47
+ * `App.layer` always provides **both** databases, and **building it opens
48
+ * and migrates both eagerly** — `store.db` and `cache.db` exist on disk the
49
+ * moment the layer is built, whether or not the program ever touches them.
50
+ * Passing no `cache` options still opens `cache.db`, because `CacheOptions`
51
+ * are all-optional and absence means defaults. An application that wants
52
+ * only one composes {@link App.layerDirs} with `AppStore.layer` or
53
+ * `AppCache.layer` and never opens the other file.
54
+ *
55
+ * A CLI should therefore not provide `App.layer` at its entry point: every
56
+ * command, `--help`-adjacent paths included, would create and migrate both
57
+ * databases, and a command that deletes the app's files would delete a
58
+ * database its own process holds open. Provide {@link App.layerDirs} once at
59
+ * the platform edge instead, bind `AppStore.layer(...)` / `AppCache.layer(...)`
60
+ * once at module scope, and attach them with `Command.provide` only on the
61
+ * commands that use them — see the second example.
62
+ *
63
+ * A module-scope binding such as `StoreLive` stays ONE connection however
64
+ * many composites reuse it inside one provided layer graph —
65
+ * `Layer.mergeAll(StoreLive, …)` beside `Repo.pipe(Layer.provide(StoreLive))`
66
+ * — because a layer graph memoises by reference, and an `Effect.provide`
67
+ * nested inside another reuses what the enclosing one already built.
68
+ * Provides that are not nested — one after another, or side by side under a
69
+ * common parent — each build it, and so open the database, again.
51
70
  *
52
71
  * This is a layer-returning function: bind the result to a `const` once and
53
72
  * reuse that binding. Calling it inline at two provide sites opens two
@@ -75,9 +94,89 @@ var App = class {
75
94
  * Layer.provide(NodeServices.layer),
76
95
  * );
77
96
  * ```
97
+ *
98
+ * @example
99
+ * Databases per command, directories everywhere — the CLI shape:
100
+ * ```ts
101
+ * import { App, AppStore } from "@effected/app";
102
+ * import { CliRuntime } from "@effected/cli";
103
+ * import type { StoreMigration } from "@effected/store";
104
+ * import { Store } from "@effected/store";
105
+ * import { NodeRuntime, NodeServices } from "@effect/platform-node";
106
+ * import { Effect, Layer } from "effect";
107
+ * import { Command } from "effect/cli";
108
+ *
109
+ * const migrations: ReadonlyArray<StoreMigration> = [
110
+ * { id: 1, name: "runs", up: (sql) => sql`CREATE TABLE runs (id TEXT PRIMARY KEY)` },
111
+ * ];
112
+ *
113
+ * // Module scope, bound once: built once per provided layer graph.
114
+ * const StoreLive = AppStore.layer({ migrations });
115
+ *
116
+ * // Opens store.db — only when `history` runs.
117
+ * const history = Command.make("history", {}, () =>
118
+ * Effect.gen(function* () {
119
+ * const store = yield* Store;
120
+ * yield* store.client`SELECT id FROM runs`;
121
+ * }),
122
+ * ).pipe(Command.provide(StoreLive));
123
+ *
124
+ * // Opens no database at all.
125
+ * const where = Command.make("where", {}, () => Effect.void);
126
+ *
127
+ * const cli = Command.make("myapp").pipe(Command.withSubcommands([history, where]));
128
+ *
129
+ * // Directories at the edge, once; AppDirs satisfies StoreLive's requirement.
130
+ * const PlatformLive = App.layerDirs({ namespace: "myapp" }).pipe(Layer.provideMerge(NodeServices.layer));
131
+ *
132
+ * NodeRuntime.runMain(CliRuntime.main(Command.run(cli, { version: "1.0.0" }), { platform: PlatformLive }));
133
+ * ```
78
134
  */
79
135
  static layer = layer;
80
136
  /**
137
+ * Build the directories half of the control plane alone: `Xdg` and
138
+ * `AppDirs` for one namespace, with no database opened.
139
+ *
140
+ * @remarks
141
+ * Exactly the directory composition {@link App.layer} builds on —
142
+ * `AppDirs.layer(options)` `provideMerge` `Xdg.layer` — lifted out so an
143
+ * application can provide "directories everywhere" at its edge and attach
144
+ * the databases only where they are used. Building it resolves the XDG
145
+ * environment and nothing else: no directory is created until an `ensure*`
146
+ * member runs, and no SQLite file is touched. Its output satisfies the
147
+ * `AppDirs` requirement of `AppStore.layer`, `AppStore.layerAs`,
148
+ * `AppCache.layer`, `AppCache.layerAs` and `AppConfig.layer`.
149
+ *
150
+ * The error channel is `XdgEnvError` alone — an unset `HOME` — and `R` is
151
+ * the `FileSystem` and `Path` a platform layer supplies once. `options` is
152
+ * `AppDirsOptions` pass-through: `namespace`, `native`, `fallbackDir` and
153
+ * `dirs` mean exactly what `@effected/xdg` documents.
154
+ *
155
+ * This is a layer-returning function: bind the result to a `const` once and
156
+ * reuse that binding.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * import { App, AppStore } from "@effected/app";
161
+ * import { NodeServices } from "@effect/platform-node";
162
+ * import { AppDirs } from "@effected/xdg";
163
+ * import { Effect, Layer } from "effect";
164
+ *
165
+ * // Bound once, to a const: XDG directories for "myapp", no databases.
166
+ * const DirsLive = App.layerDirs({ namespace: "myapp" }).pipe(Layer.provideMerge(NodeServices.layer));
167
+ *
168
+ * const program = Effect.gen(function* () {
169
+ * const dirs = yield* AppDirs;
170
+ * return dirs.dirs.state; // resolved, not created
171
+ * });
172
+ *
173
+ * // A database, attached where it is wanted, over the same directories.
174
+ * const StoreLive = AppStore.layer({ migrations: [] });
175
+ * const withStore = program.pipe(Effect.provide(StoreLive), Effect.provide(DirsLive));
176
+ * ```
177
+ */
178
+ static layerDirs = layerDirs;
179
+ /**
81
180
  * The hermetic control plane: fixed XDG paths, `:memory:` databases, and the
82
181
  * platform layers provided internally.
83
182
  *
package/AppCache.js CHANGED
@@ -1,45 +1,121 @@
1
- import { badFilename } from "./internal/filename.js";
1
+ import { ensureLocation, resolveLocation } from "./internal/location.js";
2
2
  import { Cache } from "@effected/store";
3
- import { AppDirs } from "@effected/xdg";
4
- import { Effect, Layer, Path } from "effect";
3
+ import { Effect, Layer } from "effect";
5
4
 
6
5
  //#region src/AppCache.ts
7
- const layer = (options) => Layer.unwrap(Effect.gen(function* () {
8
- const opts = options ?? {};
9
- const filename = opts.filename ?? "cache.db";
10
- const invalid = badFilename("AppCache.layer", filename);
11
- if (invalid !== void 0) return yield* Effect.die(invalid);
12
- const appDirs = yield* AppDirs;
13
- const path = yield* Path.Path;
14
- const cacheDir = yield* appDirs.ensureCache;
15
- return Cache.layerSqlite({
16
- ...opts,
17
- filename: path.join(cacheDir, filename)
6
+ /** The one derivation of the file's location, shared by `location` and both layers. */
7
+ const locate = (context, options) => {
8
+ const { filename = "cache.db", directory = "cache", subdir } = options ?? {};
9
+ return resolveLocation(context, directory, subdir, filename);
10
+ };
11
+ const build = (context, options, open) => Layer.unwrap(Effect.gen(function* () {
12
+ const { filename: _filename, directory: _directory, subdir: _subdir, ...rest } = options ?? {};
13
+ const location = yield* locate(context, options);
14
+ yield* ensureLocation(location);
15
+ return open({
16
+ ...rest,
17
+ filename: location.file
18
18
  });
19
19
  }));
20
+ const location = (options) => Effect.map(locate("AppCache.location", options), (resolved) => resolved.file);
21
+ const layer = (options) => build("AppCache.layer", options, Cache.layerSqlite);
22
+ const layerAs = (tag, options) => build("AppCache.layerAs", options, (resolved) => Cache.layerSqliteAs(tag, resolved));
20
23
  /**
21
- * The cache-directory database glue: a TTL `Cache` whose file lives in the
22
- * ambient `AppDirs` cache directory.
24
+ * The cache database glue: a TTL `Cache` whose file lives in one of the
25
+ * ambient `AppDirs` directories — the cache directory unless told otherwise.
23
26
  *
24
27
  * @public
25
28
  */
26
29
  var AppCache = class {
27
30
  constructor() {}
28
31
  /**
29
- * Build the cache-directory database layer: `AppDirs.ensureCache`, then
30
- * `Cache.layerSqlite` at `<cache dir>/<filename>`.
32
+ * Build the cache database layer: ensure `<directory>[/<subdir>]`, then
33
+ * `Cache.layerSqlite` at `<directory>[/<subdir>]/<filename>` — by default
34
+ * `AppDirs.ensureCache` and `<cache dir>/cache.db`.
31
35
  *
32
36
  * @remarks
33
37
  * The same ensure-before-open ordering as `AppStore.layer`, and it matters
34
38
  * *more* here: the cache directory is the one an operator is most likely to
35
39
  * have deleted between runs. `options` is optional because every
36
- * `CacheOptions` field is.
40
+ * `CacheOptions` field is. A `subdir` is created with a recursive `mkdir`
41
+ * after the directory is ensured — that is why `FileSystem` is in `R` — and
42
+ * its failure lands on the same typed `AppDirsError`.
37
43
  *
38
44
  * This is a layer-returning function: bind the result to a `const` and reuse
39
45
  * that binding, or memoization by reference is lost and the database is
40
46
  * opened twice.
41
47
  */
42
48
  static layer = layer;
49
+ /**
50
+ * Build an additional cache under a service key the consumer defines: the
51
+ * same ensure-before-open glue as {@link AppCache.layer}, over
52
+ * `Cache.layerSqliteAs`, provided as `I` instead of `Cache`.
53
+ *
54
+ * @remarks
55
+ * For an application that keeps more than one cache — each with its own
56
+ * file, size bound, TTL default and `CacheEvent` stream. `tag` is a
57
+ * consumer-defined `Context.Service` whose service type is `CacheShape`.
58
+ * An incompatible shape is a compile error, and so is one that adds members
59
+ * (`CacheShape & { … }`), reported as an argument "not assignable to
60
+ * parameter of type 'never'". The check cannot see through method-syntax
61
+ * parameter bivariance: a member redeclared as a method with a wider
62
+ * parameter still compiles. The
63
+ * output is `I` alone: the `Cache` built internally never leaks, so
64
+ * `layerAs` composes beside `AppCache.layer` without either shadowing the
65
+ * other.
66
+ *
67
+ * `filename` is **required** here, unlike on `AppCache.layer`. A defaulted
68
+ * `cache.db` would silently land on the primary cache's file — two
69
+ * connections onto one database, with two independent eviction passes and
70
+ * two event streams that each see half the writes. Give each cache its own
71
+ * name. The same single-path-component guard applies, and a bad name dies
72
+ * at construction.
73
+ *
74
+ * This is a layer-returning function: bind the result to a `const` once and
75
+ * reuse that binding, or memoization by reference is lost and the database
76
+ * is opened twice.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { AppCache } from "@effected/app";
81
+ * import type { CacheShape } from "@effected/store";
82
+ * import { Context } from "effect";
83
+ *
84
+ * class TarballCache extends Context.Service<TarballCache, CacheShape>()("myapp/TarballCache") {}
85
+ *
86
+ * // Bound once, to a const: <cache dir>/tarballs.db, provided as TarballCache.
87
+ * const TarballCacheLive = AppCache.layerAs(TarballCache, { filename: "tarballs.db", maxEntries: 200 });
88
+ * // Requires AppDirs, Path and FileSystem — App.layerDirs and the platform layer supply them.
89
+ * ```
90
+ */
91
+ static layerAs = layerAs;
92
+ /**
93
+ * Resolve the absolute path of the database file the layers would open for
94
+ * these options — `directory`, `subdir` and `filename` — without creating
95
+ * anything.
96
+ *
97
+ * @remarks
98
+ * The same derivation `AppCache.layer` and `AppCache.layerAs` use, not a copy of
99
+ * it, so the path a consumer reports or persists is the path the layer
100
+ * opens. Requires only `AppDirs` and `Path`: nothing is touched on disk.
101
+ * `filename` defaults to `"cache.db"` as on `AppCache.layer`; pass the same
102
+ * options you pass the layer. A bad `filename` or `subdir` dies, exactly as
103
+ * it would at layer construction.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * import { AppCache } from "@effected/app";
108
+ * import { Effect } from "effect";
109
+ *
110
+ * const options = { filename: "registry.db", directory: "data" } as const;
111
+ *
112
+ * const program = Effect.gen(function* () {
113
+ * const file = yield* AppCache.location(options); // e.g. ~/.local/share/myapp/registry.db
114
+ * yield* Effect.log(`database: ${file}`);
115
+ * });
116
+ * ```
117
+ */
118
+ static location = location;
43
119
  };
44
120
 
45
121
  //#endregion
package/AppConfig.js CHANGED
@@ -72,6 +72,14 @@ var AppConfig = class {
72
72
  * `App.layer` — the two-strings drift where an app passes `"myapp"` to
73
73
  * `App.layer` and `"my-app"` to its config preset cannot happen.
74
74
  *
75
+ * `tag` is a `ConfigFile.Service` key — its service type
76
+ * `ConfigFileShape<A>` for the schema's `A`. A key whose shape adds members
77
+ * (`ConfigFileShape<A> & { … }`) is a compile error, reported as an argument
78
+ * "not assignable to parameter of type 'never'", because this layer could
79
+ * not supply them. The check cannot see through method-syntax parameter
80
+ * bivariance: a member redeclared as a method with a wider parameter still
81
+ * compiles.
82
+ *
75
83
  * This is a layer-returning function: bind the result to a `const` and reuse
76
84
  * that binding, or two provide sites mint two independent service instances.
77
85
  *
package/AppStore.js CHANGED
@@ -1,32 +1,37 @@
1
- import { badFilename } from "./internal/filename.js";
1
+ import { ensureLocation, resolveLocation } from "./internal/location.js";
2
2
  import { Store } from "@effected/store";
3
- import { AppDirs } from "@effected/xdg";
4
- import { Effect, Layer, Path } from "effect";
3
+ import { Effect, Layer } from "effect";
5
4
 
6
5
  //#region src/AppStore.ts
7
- const layer = (options) => Layer.unwrap(Effect.gen(function* () {
8
- const filename = options.filename ?? "store.db";
9
- const invalid = badFilename("AppStore.layer", filename);
10
- if (invalid !== void 0) return yield* Effect.die(invalid);
11
- const appDirs = yield* AppDirs;
12
- const path = yield* Path.Path;
13
- const stateDir = yield* appDirs.ensureState;
14
- return Store.layerSqlite({
15
- ...options,
16
- filename: path.join(stateDir, filename)
6
+ /** The one derivation of the file's location, shared by `location` and both layers. */
7
+ const locate = (context, options) => {
8
+ const { filename = "store.db", directory = "state", subdir } = options;
9
+ return resolveLocation(context, directory, subdir, filename);
10
+ };
11
+ const build = (context, options, open) => Layer.unwrap(Effect.gen(function* () {
12
+ const { filename: _filename, directory: _directory, subdir: _subdir, ...rest } = options;
13
+ const location = yield* locate(context, options);
14
+ yield* ensureLocation(location);
15
+ return open({
16
+ ...rest,
17
+ filename: location.file
17
18
  });
18
19
  }));
20
+ const location = (options) => Effect.map(locate("AppStore.location", options), (resolved) => resolved.file);
21
+ const layer = (options) => build("AppStore.layer", options, Store.layerSqlite);
22
+ const layerAs = (tag, options) => build("AppStore.layerAs", options, (resolved) => Store.layerSqliteAs(tag, resolved));
19
23
  /**
20
- * The state-directory database glue: a migrated SQLite `Store` whose file
21
- * lives in the ambient `AppDirs` state directory.
24
+ * The database glue: a migrated SQLite `Store` whose file lives in one of the
25
+ * ambient `AppDirs` directories — the state directory unless told otherwise.
22
26
  *
23
27
  * @public
24
28
  */
25
29
  var AppStore = class {
26
30
  constructor() {}
27
31
  /**
28
- * Build the state-directory database layer: `AppDirs.ensureState`, then
29
- * `Store.layerSqlite` at `<state dir>/<filename>`.
32
+ * Build the database layer: ensure `<directory>[/<subdir>]`, then
33
+ * `Store.layerSqlite` at `<directory>[/<subdir>]/<filename>` — by default
34
+ * `AppDirs.ensureState` and `<state dir>/store.db`.
30
35
  *
31
36
  * @remarks
32
37
  * The ensure-before-open ordering is the load-bearing glue.
@@ -35,13 +40,102 @@ var AppStore = class {
35
40
  * `AppDirsError` channel. Running the ensure inside `Layer.unwrap`, before the
36
41
  * store layer is built, converts a defect surface into a typed one — "the
37
42
  * state directory could not be created" is an expected, recoverable boundary
38
- * failure and it stays on `E`. Nothing is `orDie`d.
43
+ * failure and it stays on `E`. Nothing is `orDie`d. A `subdir` is created
44
+ * with a recursive `mkdir` after the directory is ensured — that is why
45
+ * `FileSystem` is in `R` — and its failure lands on the same `AppDirsError`,
46
+ * naming the directory kind and the full path.
39
47
  *
40
48
  * This is a layer-returning function: bind the result to a `const` and reuse
41
49
  * that binding, or memoization by reference is lost and the database is
42
50
  * opened twice.
43
51
  */
44
52
  static layer = layer;
53
+ /**
54
+ * Build an additional database under a service key the consumer defines:
55
+ * the same ensure-before-open glue as {@link AppStore.layer}, over
56
+ * `Store.layerSqliteAs`, provided as `I` instead of `Store`.
57
+ *
58
+ * @remarks
59
+ * For an application that keeps more than one SQLite database — each with
60
+ * its own migrations, its own ledger, and its own `directory` / `subdir`. `tag` is a
61
+ * consumer-defined `Context.Service` whose service type is `StoreShape`.
62
+ * An incompatible shape is a compile error, and so is one that adds members
63
+ * (`StoreShape & { … }`), reported as an argument "not assignable to
64
+ * parameter of type 'never'". The check cannot see through method-syntax
65
+ * parameter bivariance: a member redeclared as a method with a wider
66
+ * parameter still compiles. The
67
+ * output is `I` alone: the `Store` the layer builds internally is provided
68
+ * to the re-tagging step and never leaks, so `layerAs` composes beside
69
+ * `AppStore.layer` without either shadowing the other.
70
+ *
71
+ * `filename` is **required** here, unlike on `AppStore.layer`. A defaulted
72
+ * `store.db` would silently land on the primary store's file — two
73
+ * connections and two migration ledgers on one database, each recording
74
+ * the other's migrations as unknown. Give each store its own name. The same
75
+ * single-path-component guard applies, and a bad name dies at construction.
76
+ *
77
+ * This is a layer-returning function: bind the result to a `const` once and
78
+ * reuse that binding, or memoization by reference is lost and the database
79
+ * is opened twice.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * import { AppStore } from "@effected/app";
84
+ * import type { StoreMigration, StoreShape } from "@effected/store";
85
+ * import { Context, Effect } from "effect";
86
+ *
87
+ * class RegistryStore extends Context.Service<RegistryStore, StoreShape>()("myapp/RegistryStore") {}
88
+ *
89
+ * const registryMigrations: ReadonlyArray<StoreMigration> = [
90
+ * { id: 1, name: "packages", up: (sql) => sql`CREATE TABLE packages (name TEXT PRIMARY KEY)` },
91
+ * ];
92
+ *
93
+ * // Bound once, to a const: <state dir>/registry.db, provided as RegistryStore.
94
+ * const RegistryStoreLive = AppStore.layerAs(RegistryStore, {
95
+ * filename: "registry.db",
96
+ * migrations: registryMigrations,
97
+ * });
98
+ *
99
+ * const program = Effect.gen(function* () {
100
+ * const registry = yield* RegistryStore;
101
+ * yield* registry.client`INSERT INTO packages (name) VALUES ('effect')`;
102
+ * });
103
+ *
104
+ * // A per-project database under the data directory: <data dir>/<projectKey>/data.db.
105
+ * // Call it once per key and bind the result — each call is a new layer.
106
+ * const projectStore = (projectKey: string) =>
107
+ * AppStore.layerAs(RegistryStore, { filename: "data.db", directory: "data", subdir: projectKey, migrations: [] });
108
+ * // Requires AppDirs, Path and FileSystem — App.layerDirs and the platform layer supply them.
109
+ * ```
110
+ */
111
+ static layerAs = layerAs;
112
+ /**
113
+ * Resolve the absolute path of the database file the layers would open for
114
+ * these options — `directory`, `subdir` and `filename` — without creating
115
+ * anything.
116
+ *
117
+ * @remarks
118
+ * The same derivation `AppStore.layer` and `AppStore.layerAs` use, not a copy of
119
+ * it, so the path a consumer reports or persists is the path the layer
120
+ * opens. Requires only `AppDirs` and `Path`: nothing is touched on disk.
121
+ * `filename` defaults to `"store.db"` as on `AppStore.layer`; pass the same
122
+ * options you pass the layer. A bad `filename` or `subdir` dies, exactly as
123
+ * it would at layer construction.
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * import { AppStore } from "@effected/app";
128
+ * import { Effect } from "effect";
129
+ *
130
+ * const options = { filename: "registry.db", directory: "data", migrations: [] } as const;
131
+ *
132
+ * const program = Effect.gen(function* () {
133
+ * const file = yield* AppStore.location(options); // e.g. ~/.local/share/myapp/registry.db
134
+ * yield* Effect.log(`database: ${file}`);
135
+ * });
136
+ * ```
137
+ */
138
+ static location = location;
45
139
  };
46
140
 
47
141
  //#endregion
package/README.md CHANGED
@@ -111,7 +111,15 @@ Four services, one platform import, one namespace typed once, and every failure
111
111
 
112
112
  `AppOptions` is [`@effected/xdg`](../xdg)'s `AppDirsOptions` straight through — `namespace`, `native`, `fallbackDir` and `dirs` mean there exactly what they mean here, five-rung precedence ladder included, and this package re-documents none of it.
113
113
 
114
- Every `filename` takes a **single path component**. An empty name, one containing a separator, or `.` / `..` would escape the namespace directory, so it dies at layer construction: it can only come from code, never from user input.
114
+ `AppStore` and `AppCache` take a `directory` (`"state"`, `"data"` or `"cache"`; defaults as above) and an optional relative `subdir` under it, created with `mkdir -p` before the database opens — so `{ directory: "data", subdir: "projects/abc123", filename: "data.db" }` lands at `~/.local/share/myapp/projects/abc123/data.db`. A `subdir` that cannot be created fails on the same typed `AppDirsError` as the directory itself; because of that `mkdir`, the database layers need `FileSystem` as well as `AppDirs` and `Path`.
115
+
116
+ Every `filename` takes a **single path component**, and every `subdir` component obeys the same rule (no empty component, no `.` or `..`, no leading `/`, no `\`). Both are wiring, so a bad one **dies** rather than failing typed. A `subdir` derived from runtime data — a per-project key from a path — must therefore come from a derivation that can only produce valid components, such as a hash:
117
+
118
+ ```ts
119
+ import { createHash } from "node:crypto";
120
+
121
+ const subdir = createHash("sha256").update(projectRoot).digest("hex").slice(0, 16);
122
+ ``` An empty name, one containing a separator, or `.` / `..` would escape the namespace directory, so it dies at layer construction: it can only come from code, never from user input.
115
123
 
116
124
  ## The namespace is typed once
117
125
 
@@ -194,6 +202,87 @@ const AppLive = App.layer({ namespace: "myapp", store: { migrations } }); // onc
194
202
 
195
203
  Call the factory inline at two provide sites and you open **two databases**: two connections onto one file, two migration ledgers, and two independent `CacheEvent` PubSubs whose subscribers each see half the events. This is the package where an application is most likely to compose the same layer twice, which is why the rule is here and not in a footnote.
196
204
 
205
+ ## Databases per command: directories everywhere
206
+
207
+ Building `App.layer` **opens and migrates both databases**, eagerly — `store.db` and `cache.db` exist the moment the layer is built, whether or not the program touches them. For a long-running service that is what you want. For a CLI it is not: provide `App.layer` at the entry point and every command creates and migrates both files, `--help`-adjacent paths included, and a command that deletes the app's files ends up deleting a database its own process holds open.
208
+
209
+ `App.layerDirs` is the directories half on its own — `Xdg` and `AppDirs` for one namespace, `XdgEnvError` on the error channel, `FileSystem` and `Path` in `R`, and no database opened. Provide it once at the edge, bind each database layer once at module scope, and attach it with `Command.provide` only on the commands that use it:
210
+
211
+ ```ts
212
+ import { App, AppStore } from "@effected/app";
213
+ import { CliRuntime } from "@effected/cli";
214
+ import { Store } from "@effected/store";
215
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
216
+ import { Effect, Layer } from "effect";
217
+ import { Command } from "effect/cli";
218
+
219
+ // Module scope, bound once: built once per provided layer graph.
220
+ const StoreLive = AppStore.layer({ migrations });
221
+
222
+ // Opens store.db — only when `history` runs.
223
+ const history = Command.make("history", {}, () =>
224
+ Effect.gen(function* () {
225
+ const store = yield* Store;
226
+ yield* store.client`SELECT id FROM runs`;
227
+ }),
228
+ ).pipe(Command.provide(StoreLive));
229
+
230
+ // Opens no database at all.
231
+ const where = Command.make("where", {}, () => Effect.void);
232
+
233
+ const cli = Command.make("myapp").pipe(Command.withSubcommands([history, where]));
234
+
235
+ // Directories at the edge, once; AppDirs satisfies StoreLive's requirement.
236
+ const PlatformLive = App.layerDirs({ namespace: "myapp" }).pipe(Layer.provideMerge(NodeServices.layer));
237
+
238
+ NodeRuntime.runMain(CliRuntime.main(Command.run(cli, { version: "1.0.0" }), { platform: PlatformLive }));
239
+ ```
240
+
241
+ A module-scope binding such as `StoreLive` stays **one** connection however many composites reuse it inside one provided layer graph — `Layer.mergeAll(StoreLive, …)` beside `Repo.pipe(Layer.provide(StoreLive))` — because a layer graph memoises by reference, and an `Effect.provide` nested inside another reuses what the enclosing one already built. Provides that are not nested — one after another, or side by side under a common parent — each build it, and so open the database, again.
242
+
243
+ `Command.provide` also accepts a function of the command's parsed input, `(input) => Layer`, for a database whose options depend on a flag. Bind anything that does not depend on the input outside that function — a layer built inside it is a new reference on every call.
244
+
245
+ ## More than one store
246
+
247
+ `AppStore.layer` and `AppCache.layer` provide the kit's single `Store` and `Cache` services. An application that keeps a second database — a registry beside its run history, a separate cache for large objects — declares its own service key over the same shape and builds it with `layerAs`:
248
+
249
+ ```ts
250
+ import { AppCache, AppStore } from "@effected/app";
251
+ import type { CacheShape, StoreShape } from "@effected/store";
252
+ import { Context, Layer } from "effect";
253
+
254
+ class RegistryStore extends Context.Service<RegistryStore, StoreShape>()("myapp/RegistryStore") {}
255
+ class TarballCache extends Context.Service<TarballCache, CacheShape>()("myapp/TarballCache") {}
256
+
257
+ // Every one bound once, to a const.
258
+ const StoreLive = AppStore.layer({ migrations });
259
+ const RegistryStoreLive = AppStore.layerAs(RegistryStore, { filename: "registry.db", migrations: registryMigrations });
260
+ const TarballCacheLive = AppCache.layerAs(TarballCache, { filename: "tarballs.db", maxEntries: 200 });
261
+
262
+ const DatabasesLive = Layer.mergeAll(StoreLive, RegistryStoreLive, TarballCacheLive);
263
+ ```
264
+
265
+ Each keyed layer runs the same ensure-before-open glue, in the same default directory as its primary — state for stores, cache for caches, unless `directory` / `subdir` say otherwise — and outputs **only its own key**: the `Store` or `Cache` it builds internally never leaks, so it composes beside the primary without shadowing it. Each file has its own migrations and its own ledger.
266
+
267
+ - **`filename` is required.** A default of `store.db` or `cache.db` would land a keyed layer silently on the primary's file — two connections and two migration ledgers on one database.
268
+ - **The key's service type must be `StoreShape` or `CacheShape`.** An incompatible shape is a compile error, and so is one that adds members (`StoreShape & { … }`), reported as an argument not assignable to `never` because this layer cannot supply them. The check cannot see through method-syntax parameter bivariance: a member redeclared as a method with a wider parameter still compiles.
269
+ - **Bind every keyed layer to a `const`.** Each `layerAs(…)` call is a new layer, and the memoization trap above applies to each one separately: two inline calls with the same key open the file twice.
270
+
271
+ The keyed layers need `AppDirs`, `Path` and `FileSystem`, which is exactly what `App.layerDirs` plus the platform provides — they slot into the per-command shape above unchanged.
272
+
273
+ Every `AppStore` / `AppCache` option beyond the path is `@effected/store`'s, passed straight through: `client` (driver options such as `busyTimeout` and `disableWAL` — the place for per-connection settings, never a migration), `checkpointOnClose`, `onConnect` (a per-connection hook run before the ledger, outside any transaction), and on stores `adoptMigratorLedger` and `mirrorMigratorLedger`, which move a database previously migrated by effect/sql's `Migrator` onto `Store` without re-running its history and keep that ledger current for older versions (see `@effected/store`'s README for the rules). A database at an absolute or host-chosen path is not an app concern: use `Store.layerSqliteAs` / `Cache.layerSqliteAs` from `@effected/store` directly.
274
+
275
+ ### Where is the file?
276
+
277
+ A program that reports or persists the database path — a `doctor` command, a "your data lives at" line — derives it with `AppStore.location(options)` / `AppCache.location(options)`, passing the same options it passes the layer. It resolves the absolute path from the ambient `AppDirs` without creating anything (`R` is `AppDirs | Path`), and it is the very derivation the layers use, so the reported path and the opened file cannot disagree:
278
+
279
+ ```ts
280
+ const options = { filename: "registry.db", directory: "data", migrations } as const;
281
+
282
+ const RegistryStoreLive = AppStore.layerAs(RegistryStore, options);
283
+ const where = AppStore.location(options); // Effect<string, never, AppDirs | Path>
284
+ ```
285
+
197
286
  ## Testing: one line, no platform package
198
287
 
199
288
  `App.layerTest` is the hermetic control plane — fixed XDG paths, `:memory:` databases, and the platform layers provided *internally* rather than merged into the output. A consumer's first test needs no platform import at all:
@@ -255,8 +344,10 @@ Provide it beneath the stack and every span the kit's packages already emit —
255
344
  ## Features
256
345
 
257
346
  - `App.layer` — the control plane: `Xdg`, `AppDirs`, `Store` and `Cache` from one call, with only `FileSystem` and `Path` left for the platform layer to supply.
347
+ - `App.layerDirs` — `Xdg` and `AppDirs` alone, opening no database: directories everywhere, databases attached only where they are used.
258
348
  - `App.layerTest` — the same four services, hermetic: synthetic XDG paths, `:memory:` databases, no platform package required.
259
349
  - `AppStore.layer` / `AppCache.layer` — the state-directory and cache-directory databases on their own, each ensuring its directory before it opens the file.
350
+ - `AppStore.layerAs` / `AppCache.layerAs` — an additional database under a service key you define, with its own file, migrations and ledger.
260
351
  - `AppConfig.layer` — `@effected/config-file` wired to xdg's resolver chain and save path, with the namespace read from the ambient `AppDirs` and the codec named by you.
261
352
  - `AppError` — the type-only union of everything the control plane can fail with, for the `catchTags` block at the edge.
262
353
 
package/index.d.ts CHANGED
@@ -1,77 +1,218 @@
1
- import { Cache, CacheError, CacheOptions, Store, StoreError, StoreMigrationError, StoreOptions } from "@effected/store";
1
+ import { Cache, CacheError, CacheOptions, CacheShape, CacheSqliteOptions, Store, StoreError, StoreMigrationError, StoreOptions, StoreShape, StoreSqliteOptions } from "@effected/store";
2
2
  import { AppDirs, AppDirsError, AppDirsOptions, Xdg, XdgEnvError, XdgPaths } from "@effected/xdg";
3
3
  import { Context, Effect, FileSystem, Layer, Path, Schema, SchemaAST } from "effect";
4
4
  import { ConfigCodec, ConfigEvents, ConfigEventsShape, ConfigFileShape, ConfigResolver, ConfigValidationError, MergeStrategy } from "@effected/config-file";
5
5
  //#region src/AppCache.d.ts
6
6
  /**
7
- * Options for {@link AppCache.layer}.
7
+ * Options for {@link AppCache.layer} and {@link AppCache.layerAs}.
8
+ *
9
+ * @remarks
10
+ * `CacheOptions` plus the SQLite driver passthrough — `client`,
11
+ * `checkpointOnClose` — exactly as `Cache.layerSqlite` takes them. Only the
12
+ * path is this package's: a directory kind, an optional subdirectory under
13
+ * it, and a file name.
8
14
  *
9
15
  * @public
10
16
  */
11
- interface AppCacheOptions extends CacheOptions {
17
+ interface AppCacheOptions extends CacheOptions, Pick<CacheSqliteOptions, "client" | "checkpointOnClose" | "onConnect"> {
12
18
  /**
13
- * File name within the app's cache directory. Default `"cache.db"`.
19
+ * File name within the database's directory. Default `"cache.db"` on
20
+ * `AppCache.layer`; required on `AppCache.layerAs`.
14
21
  *
15
22
  * @remarks
16
- * A single path component. An empty name, or one containing a separator,
17
- * would escape the namespace directory, so it **dies** at layer
23
+ * A single path component. An empty name, one containing a separator, or
24
+ * `.` / `..` would escape the namespace directory, so it **dies** at layer
18
25
  * construction — it can only come from code, never from user input.
19
26
  */
20
27
  readonly filename?: string;
28
+ /**
29
+ * Which of the app's directories holds the file. Default `"cache"`.
30
+ *
31
+ * @remarks
32
+ * The matching `AppDirs.ensure*` member creates it before the database
33
+ * opens.
34
+ */
35
+ readonly directory?: "state" | "data" | "cache";
36
+ /**
37
+ * A relative subdirectory under `directory`, created (`mkdir -p`) before
38
+ * the database opens.
39
+ *
40
+ * @remarks
41
+ * `/`-separated single path components: no empty component, no `.` or
42
+ * `..`, no leading `/`, no `\`. Anything else would escape the namespace
43
+ * directory, so it **dies** at layer construction. A failure to create it
44
+ * is a typed `AppDirsError` naming `directory` and the full path.
45
+ *
46
+ * A subdir derived from runtime data — a project path, a user-supplied
47
+ * name — must come from a derivation that can only produce a valid
48
+ * component, such as a hash:
49
+ * `subdir: createHash("sha256").update(projectRoot).digest("hex").slice(0, 16)`.
50
+ * Passed raw, an unexpected value is a defect, not a typed failure.
51
+ */
52
+ readonly subdir?: string;
21
53
  }
22
54
  /**
23
- * The cache-directory database glue: a TTL `Cache` whose file lives in the
24
- * ambient `AppDirs` cache directory.
55
+ * The cache database glue: a TTL `Cache` whose file lives in one of the
56
+ * ambient `AppDirs` directories — the cache directory unless told otherwise.
25
57
  *
26
58
  * @public
27
59
  */
28
60
  export declare class AppCache {
29
61
  private constructor();
30
62
  /**
31
- * Build the cache-directory database layer: `AppDirs.ensureCache`, then
32
- * `Cache.layerSqlite` at `<cache dir>/<filename>`.
63
+ * Build the cache database layer: ensure `<directory>[/<subdir>]`, then
64
+ * `Cache.layerSqlite` at `<directory>[/<subdir>]/<filename>` — by default
65
+ * `AppDirs.ensureCache` and `<cache dir>/cache.db`.
33
66
  *
34
67
  * @remarks
35
68
  * The same ensure-before-open ordering as `AppStore.layer`, and it matters
36
69
  * *more* here: the cache directory is the one an operator is most likely to
37
70
  * have deleted between runs. `options` is optional because every
38
- * `CacheOptions` field is.
71
+ * `CacheOptions` field is. A `subdir` is created with a recursive `mkdir`
72
+ * after the directory is ensured — that is why `FileSystem` is in `R` — and
73
+ * its failure lands on the same typed `AppDirsError`.
39
74
  *
40
75
  * This is a layer-returning function: bind the result to a `const` and reuse
41
76
  * that binding, or memoization by reference is lost and the database is
42
77
  * opened twice.
43
78
  */
44
- static readonly layer: (options?: AppCacheOptions) => Layer.Layer<Cache, AppDirsError | CacheError, AppDirs | Path.Path>;
79
+ static readonly layer: (options?: AppCacheOptions) => Layer.Layer<Cache, AppDirsError | CacheError, AppDirs | Path.Path | FileSystem.FileSystem>;
80
+ /**
81
+ * Build an additional cache under a service key the consumer defines: the
82
+ * same ensure-before-open glue as {@link AppCache.layer}, over
83
+ * `Cache.layerSqliteAs`, provided as `I` instead of `Cache`.
84
+ *
85
+ * @remarks
86
+ * For an application that keeps more than one cache — each with its own
87
+ * file, size bound, TTL default and `CacheEvent` stream. `tag` is a
88
+ * consumer-defined `Context.Service` whose service type is `CacheShape`.
89
+ * An incompatible shape is a compile error, and so is one that adds members
90
+ * (`CacheShape & { … }`), reported as an argument "not assignable to
91
+ * parameter of type 'never'". The check cannot see through method-syntax
92
+ * parameter bivariance: a member redeclared as a method with a wider
93
+ * parameter still compiles. The
94
+ * output is `I` alone: the `Cache` built internally never leaks, so
95
+ * `layerAs` composes beside `AppCache.layer` without either shadowing the
96
+ * other.
97
+ *
98
+ * `filename` is **required** here, unlike on `AppCache.layer`. A defaulted
99
+ * `cache.db` would silently land on the primary cache's file — two
100
+ * connections onto one database, with two independent eviction passes and
101
+ * two event streams that each see half the writes. Give each cache its own
102
+ * name. The same single-path-component guard applies, and a bad name dies
103
+ * at construction.
104
+ *
105
+ * This is a layer-returning function: bind the result to a `const` once and
106
+ * reuse that binding, or memoization by reference is lost and the database
107
+ * is opened twice.
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * import { AppCache } from "@effected/app";
112
+ * import type { CacheShape } from "@effected/store";
113
+ * import { Context } from "effect";
114
+ *
115
+ * class TarballCache extends Context.Service<TarballCache, CacheShape>()("myapp/TarballCache") {}
116
+ *
117
+ * // Bound once, to a const: <cache dir>/tarballs.db, provided as TarballCache.
118
+ * const TarballCacheLive = AppCache.layerAs(TarballCache, { filename: "tarballs.db", maxEntries: 200 });
119
+ * // Requires AppDirs, Path and FileSystem — App.layerDirs and the platform layer supply them.
120
+ * ```
121
+ */
122
+ static readonly layerAs: <I, S extends CacheShape>(tag: Context.Key<I, S> & ([CacheShape] extends [S] ? unknown : never), options: AppCacheOptions & {
123
+ readonly filename: string;
124
+ }) => Layer.Layer<I, AppDirsError | CacheError, AppDirs | Path.Path | FileSystem.FileSystem>;
125
+ /**
126
+ * Resolve the absolute path of the database file the layers would open for
127
+ * these options — `directory`, `subdir` and `filename` — without creating
128
+ * anything.
129
+ *
130
+ * @remarks
131
+ * The same derivation `AppCache.layer` and `AppCache.layerAs` use, not a copy of
132
+ * it, so the path a consumer reports or persists is the path the layer
133
+ * opens. Requires only `AppDirs` and `Path`: nothing is touched on disk.
134
+ * `filename` defaults to `"cache.db"` as on `AppCache.layer`; pass the same
135
+ * options you pass the layer. A bad `filename` or `subdir` dies, exactly as
136
+ * it would at layer construction.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * import { AppCache } from "@effected/app";
141
+ * import { Effect } from "effect";
142
+ *
143
+ * const options = { filename: "registry.db", directory: "data" } as const;
144
+ *
145
+ * const program = Effect.gen(function* () {
146
+ * const file = yield* AppCache.location(options); // e.g. ~/.local/share/myapp/registry.db
147
+ * yield* Effect.log(`database: ${file}`);
148
+ * });
149
+ * ```
150
+ */
151
+ static readonly location: (options?: AppCacheOptions) => Effect.Effect<string, never, AppDirs | Path.Path>;
45
152
  }
46
153
  //#endregion
47
154
  //#region src/AppStore.d.ts
48
155
  /**
49
- * Options for {@link AppStore.layer}.
156
+ * Options for {@link AppStore.layer} and {@link AppStore.layerAs}.
157
+ *
158
+ * @remarks
159
+ * `StoreOptions` (the migrations, and `adoptMigratorLedger`) plus the SQLite
160
+ * driver passthrough — `client`, `checkpointOnClose` — exactly as
161
+ * `Store.layerSqlite` takes them. Only the path is this package's: a
162
+ * directory kind, an optional subdirectory under it, and a file name.
50
163
  *
51
164
  * @public
52
165
  */
53
- interface AppStoreOptions extends StoreOptions {
166
+ interface AppStoreOptions extends StoreOptions, Pick<StoreSqliteOptions, "client" | "checkpointOnClose" | "onConnect"> {
54
167
  /**
55
- * File name within the app's state directory. Default `"store.db"`.
168
+ * File name within the database's directory. Default `"store.db"` on
169
+ * `AppStore.layer`; required on `AppStore.layerAs`.
56
170
  *
57
171
  * @remarks
58
- * A single path component. An empty name, or one containing a separator,
59
- * would escape the namespace directory, so it **dies** at layer
172
+ * A single path component. An empty name, one containing a separator, or
173
+ * `.` / `..` would escape the namespace directory, so it **dies** at layer
60
174
  * construction — it can only come from code, never from user input.
61
175
  */
62
176
  readonly filename?: string;
177
+ /**
178
+ * Which of the app's directories holds the file. Default `"state"`.
179
+ *
180
+ * @remarks
181
+ * The matching `AppDirs.ensure*` member creates it before the database
182
+ * opens. `"data"` suits a database that is the user's data rather than
183
+ * the app's bookkeeping; `"cache"` one that is safe to delete.
184
+ */
185
+ readonly directory?: "state" | "data" | "cache";
186
+ /**
187
+ * A relative subdirectory under `directory`, created (`mkdir -p`) before
188
+ * the database opens — for example a per-project key.
189
+ *
190
+ * @remarks
191
+ * `/`-separated single path components: no empty component, no `.` or
192
+ * `..`, no leading `/`, no `\`. Anything else would escape the namespace
193
+ * directory, so it **dies** at layer construction. A failure to create it
194
+ * is a typed `AppDirsError` naming `directory` and the full path.
195
+ *
196
+ * A subdir derived from runtime data — a project path, a user-supplied
197
+ * name — must come from a derivation that can only produce a valid
198
+ * component, such as a hash:
199
+ * `subdir: createHash("sha256").update(projectRoot).digest("hex").slice(0, 16)`.
200
+ * Passed raw, an unexpected value is a defect, not a typed failure.
201
+ */
202
+ readonly subdir?: string;
63
203
  }
64
204
  /**
65
- * The state-directory database glue: a migrated SQLite `Store` whose file
66
- * lives in the ambient `AppDirs` state directory.
205
+ * The database glue: a migrated SQLite `Store` whose file lives in one of the
206
+ * ambient `AppDirs` directories — the state directory unless told otherwise.
67
207
  *
68
208
  * @public
69
209
  */
70
210
  export declare class AppStore {
71
211
  private constructor();
72
212
  /**
73
- * Build the state-directory database layer: `AppDirs.ensureState`, then
74
- * `Store.layerSqlite` at `<state dir>/<filename>`.
213
+ * Build the database layer: ensure `<directory>[/<subdir>]`, then
214
+ * `Store.layerSqlite` at `<directory>[/<subdir>]/<filename>` — by default
215
+ * `AppDirs.ensureState` and `<state dir>/store.db`.
75
216
  *
76
217
  * @remarks
77
218
  * The ensure-before-open ordering is the load-bearing glue.
@@ -80,13 +221,104 @@ export declare class AppStore {
80
221
  * `AppDirsError` channel. Running the ensure inside `Layer.unwrap`, before the
81
222
  * store layer is built, converts a defect surface into a typed one — "the
82
223
  * state directory could not be created" is an expected, recoverable boundary
83
- * failure and it stays on `E`. Nothing is `orDie`d.
224
+ * failure and it stays on `E`. Nothing is `orDie`d. A `subdir` is created
225
+ * with a recursive `mkdir` after the directory is ensured — that is why
226
+ * `FileSystem` is in `R` — and its failure lands on the same `AppDirsError`,
227
+ * naming the directory kind and the full path.
84
228
  *
85
229
  * This is a layer-returning function: bind the result to a `const` and reuse
86
230
  * that binding, or memoization by reference is lost and the database is
87
231
  * opened twice.
88
232
  */
89
- static readonly layer: (options: AppStoreOptions) => Layer.Layer<Store, AppDirsError | StoreError | StoreMigrationError, AppDirs | Path.Path>;
233
+ static readonly layer: (options: AppStoreOptions) => Layer.Layer<Store, AppDirsError | StoreError | StoreMigrationError, AppDirs | Path.Path | FileSystem.FileSystem>;
234
+ /**
235
+ * Build an additional database under a service key the consumer defines:
236
+ * the same ensure-before-open glue as {@link AppStore.layer}, over
237
+ * `Store.layerSqliteAs`, provided as `I` instead of `Store`.
238
+ *
239
+ * @remarks
240
+ * For an application that keeps more than one SQLite database — each with
241
+ * its own migrations, its own ledger, and its own `directory` / `subdir`. `tag` is a
242
+ * consumer-defined `Context.Service` whose service type is `StoreShape`.
243
+ * An incompatible shape is a compile error, and so is one that adds members
244
+ * (`StoreShape & { … }`), reported as an argument "not assignable to
245
+ * parameter of type 'never'". The check cannot see through method-syntax
246
+ * parameter bivariance: a member redeclared as a method with a wider
247
+ * parameter still compiles. The
248
+ * output is `I` alone: the `Store` the layer builds internally is provided
249
+ * to the re-tagging step and never leaks, so `layerAs` composes beside
250
+ * `AppStore.layer` without either shadowing the other.
251
+ *
252
+ * `filename` is **required** here, unlike on `AppStore.layer`. A defaulted
253
+ * `store.db` would silently land on the primary store's file — two
254
+ * connections and two migration ledgers on one database, each recording
255
+ * the other's migrations as unknown. Give each store its own name. The same
256
+ * single-path-component guard applies, and a bad name dies at construction.
257
+ *
258
+ * This is a layer-returning function: bind the result to a `const` once and
259
+ * reuse that binding, or memoization by reference is lost and the database
260
+ * is opened twice.
261
+ *
262
+ * @example
263
+ * ```ts
264
+ * import { AppStore } from "@effected/app";
265
+ * import type { StoreMigration, StoreShape } from "@effected/store";
266
+ * import { Context, Effect } from "effect";
267
+ *
268
+ * class RegistryStore extends Context.Service<RegistryStore, StoreShape>()("myapp/RegistryStore") {}
269
+ *
270
+ * const registryMigrations: ReadonlyArray<StoreMigration> = [
271
+ * { id: 1, name: "packages", up: (sql) => sql`CREATE TABLE packages (name TEXT PRIMARY KEY)` },
272
+ * ];
273
+ *
274
+ * // Bound once, to a const: <state dir>/registry.db, provided as RegistryStore.
275
+ * const RegistryStoreLive = AppStore.layerAs(RegistryStore, {
276
+ * filename: "registry.db",
277
+ * migrations: registryMigrations,
278
+ * });
279
+ *
280
+ * const program = Effect.gen(function* () {
281
+ * const registry = yield* RegistryStore;
282
+ * yield* registry.client`INSERT INTO packages (name) VALUES ('effect')`;
283
+ * });
284
+ *
285
+ * // A per-project database under the data directory: <data dir>/<projectKey>/data.db.
286
+ * // Call it once per key and bind the result — each call is a new layer.
287
+ * const projectStore = (projectKey: string) =>
288
+ * AppStore.layerAs(RegistryStore, { filename: "data.db", directory: "data", subdir: projectKey, migrations: [] });
289
+ * // Requires AppDirs, Path and FileSystem — App.layerDirs and the platform layer supply them.
290
+ * ```
291
+ */
292
+ static readonly layerAs: <I, S extends StoreShape>(tag: Context.Key<I, S> & ([StoreShape] extends [S] ? unknown : never), options: AppStoreOptions & {
293
+ readonly filename: string;
294
+ }) => Layer.Layer<I, AppDirsError | StoreError | StoreMigrationError, AppDirs | Path.Path | FileSystem.FileSystem>;
295
+ /**
296
+ * Resolve the absolute path of the database file the layers would open for
297
+ * these options — `directory`, `subdir` and `filename` — without creating
298
+ * anything.
299
+ *
300
+ * @remarks
301
+ * The same derivation `AppStore.layer` and `AppStore.layerAs` use, not a copy of
302
+ * it, so the path a consumer reports or persists is the path the layer
303
+ * opens. Requires only `AppDirs` and `Path`: nothing is touched on disk.
304
+ * `filename` defaults to `"store.db"` as on `AppStore.layer`; pass the same
305
+ * options you pass the layer. A bad `filename` or `subdir` dies, exactly as
306
+ * it would at layer construction.
307
+ *
308
+ * @example
309
+ * ```ts
310
+ * import { AppStore } from "@effected/app";
311
+ * import { Effect } from "effect";
312
+ *
313
+ * const options = { filename: "registry.db", directory: "data", migrations: [] } as const;
314
+ *
315
+ * const program = Effect.gen(function* () {
316
+ * const file = yield* AppStore.location(options); // e.g. ~/.local/share/myapp/registry.db
317
+ * yield* Effect.log(`database: ${file}`);
318
+ * });
319
+ * ```
320
+ */
321
+ static readonly location: (options: AppStoreOptions) => Effect.Effect<string, never, AppDirs | Path.Path>;
90
322
  }
91
323
  //#endregion
92
324
  //#region src/App.d.ts
@@ -153,10 +385,29 @@ export declare class App {
153
385
  * so all four services come out and only `FileSystem` and `Path` stay in `R` —
154
386
  * the two the consumer's platform layer supplies once, at the edge.
155
387
  *
156
- * `App.layer` always provides **both** databases: an application that wants
157
- * only one composes `AppStore.layer` or `AppCache.layer` directly and never
158
- * opens the other file. Passing no `cache` options still opens `cache.db`,
159
- * because `CacheOptions` are all-optional and absence means defaults.
388
+ * `App.layer` always provides **both** databases, and **building it opens
389
+ * and migrates both eagerly** — `store.db` and `cache.db` exist on disk the
390
+ * moment the layer is built, whether or not the program ever touches them.
391
+ * Passing no `cache` options still opens `cache.db`, because `CacheOptions`
392
+ * are all-optional and absence means defaults. An application that wants
393
+ * only one composes {@link App.layerDirs} with `AppStore.layer` or
394
+ * `AppCache.layer` and never opens the other file.
395
+ *
396
+ * A CLI should therefore not provide `App.layer` at its entry point: every
397
+ * command, `--help`-adjacent paths included, would create and migrate both
398
+ * databases, and a command that deletes the app's files would delete a
399
+ * database its own process holds open. Provide {@link App.layerDirs} once at
400
+ * the platform edge instead, bind `AppStore.layer(...)` / `AppCache.layer(...)`
401
+ * once at module scope, and attach them with `Command.provide` only on the
402
+ * commands that use them — see the second example.
403
+ *
404
+ * A module-scope binding such as `StoreLive` stays ONE connection however
405
+ * many composites reuse it inside one provided layer graph —
406
+ * `Layer.mergeAll(StoreLive, …)` beside `Repo.pipe(Layer.provide(StoreLive))`
407
+ * — because a layer graph memoises by reference, and an `Effect.provide`
408
+ * nested inside another reuses what the enclosing one already built.
409
+ * Provides that are not nested — one after another, or side by side under a
410
+ * common parent — each build it, and so open the database, again.
160
411
  *
161
412
  * This is a layer-returning function: bind the result to a `const` once and
162
413
  * reuse that binding. Calling it inline at two provide sites opens two
@@ -184,8 +435,88 @@ export declare class App {
184
435
  * Layer.provide(NodeServices.layer),
185
436
  * );
186
437
  * ```
438
+ *
439
+ * @example
440
+ * Databases per command, directories everywhere — the CLI shape:
441
+ * ```ts
442
+ * import { App, AppStore } from "@effected/app";
443
+ * import { CliRuntime } from "@effected/cli";
444
+ * import type { StoreMigration } from "@effected/store";
445
+ * import { Store } from "@effected/store";
446
+ * import { NodeRuntime, NodeServices } from "@effect/platform-node";
447
+ * import { Effect, Layer } from "effect";
448
+ * import { Command } from "effect/cli";
449
+ *
450
+ * const migrations: ReadonlyArray<StoreMigration> = [
451
+ * { id: 1, name: "runs", up: (sql) => sql`CREATE TABLE runs (id TEXT PRIMARY KEY)` },
452
+ * ];
453
+ *
454
+ * // Module scope, bound once: built once per provided layer graph.
455
+ * const StoreLive = AppStore.layer({ migrations });
456
+ *
457
+ * // Opens store.db — only when `history` runs.
458
+ * const history = Command.make("history", {}, () =>
459
+ * Effect.gen(function* () {
460
+ * const store = yield* Store;
461
+ * yield* store.client`SELECT id FROM runs`;
462
+ * }),
463
+ * ).pipe(Command.provide(StoreLive));
464
+ *
465
+ * // Opens no database at all.
466
+ * const where = Command.make("where", {}, () => Effect.void);
467
+ *
468
+ * const cli = Command.make("myapp").pipe(Command.withSubcommands([history, where]));
469
+ *
470
+ * // Directories at the edge, once; AppDirs satisfies StoreLive's requirement.
471
+ * const PlatformLive = App.layerDirs({ namespace: "myapp" }).pipe(Layer.provideMerge(NodeServices.layer));
472
+ *
473
+ * NodeRuntime.runMain(CliRuntime.main(Command.run(cli, { version: "1.0.0" }), { platform: PlatformLive }));
474
+ * ```
187
475
  */
188
476
  static readonly layer: (options: AppOptions) => Layer.Layer<Xdg | AppDirs | Store | Cache, AppError, FileSystem.FileSystem | Path.Path>;
477
+ /**
478
+ * Build the directories half of the control plane alone: `Xdg` and
479
+ * `AppDirs` for one namespace, with no database opened.
480
+ *
481
+ * @remarks
482
+ * Exactly the directory composition {@link App.layer} builds on —
483
+ * `AppDirs.layer(options)` `provideMerge` `Xdg.layer` — lifted out so an
484
+ * application can provide "directories everywhere" at its edge and attach
485
+ * the databases only where they are used. Building it resolves the XDG
486
+ * environment and nothing else: no directory is created until an `ensure*`
487
+ * member runs, and no SQLite file is touched. Its output satisfies the
488
+ * `AppDirs` requirement of `AppStore.layer`, `AppStore.layerAs`,
489
+ * `AppCache.layer`, `AppCache.layerAs` and `AppConfig.layer`.
490
+ *
491
+ * The error channel is `XdgEnvError` alone — an unset `HOME` — and `R` is
492
+ * the `FileSystem` and `Path` a platform layer supplies once. `options` is
493
+ * `AppDirsOptions` pass-through: `namespace`, `native`, `fallbackDir` and
494
+ * `dirs` mean exactly what `@effected/xdg` documents.
495
+ *
496
+ * This is a layer-returning function: bind the result to a `const` once and
497
+ * reuse that binding.
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * import { App, AppStore } from "@effected/app";
502
+ * import { NodeServices } from "@effect/platform-node";
503
+ * import { AppDirs } from "@effected/xdg";
504
+ * import { Effect, Layer } from "effect";
505
+ *
506
+ * // Bound once, to a const: XDG directories for "myapp", no databases.
507
+ * const DirsLive = App.layerDirs({ namespace: "myapp" }).pipe(Layer.provideMerge(NodeServices.layer));
508
+ *
509
+ * const program = Effect.gen(function* () {
510
+ * const dirs = yield* AppDirs;
511
+ * return dirs.dirs.state; // resolved, not created
512
+ * });
513
+ *
514
+ * // A database, attached where it is wanted, over the same directories.
515
+ * const StoreLive = AppStore.layer({ migrations: [] });
516
+ * const withStore = program.pipe(Effect.provide(StoreLive), Effect.provide(DirsLive));
517
+ * ```
518
+ */
519
+ static readonly layerDirs: (options: AppDirsOptions) => Layer.Layer<Xdg | AppDirs, XdgEnvError, FileSystem.FileSystem | Path.Path>;
189
520
  /**
190
521
  * The hermetic control plane: fixed XDG paths, `:memory:` databases, and the
191
522
  * platform layers provided internally.
@@ -407,6 +738,14 @@ export declare class AppConfig {
407
738
  * `App.layer` — the two-strings drift where an app passes `"myapp"` to
408
739
  * `App.layer` and `"my-app"` to its config preset cannot happen.
409
740
  *
741
+ * `tag` is a `ConfigFile.Service` key — its service type
742
+ * `ConfigFileShape<A>` for the schema's `A`. A key whose shape adds members
743
+ * (`ConfigFileShape<A> & { … }`) is a compile error, reported as an argument
744
+ * "not assignable to parameter of type 'never'", because this layer could
745
+ * not supply them. The check cannot see through method-syntax parameter
746
+ * bivariance: a member redeclared as a method with a wider parameter still
747
+ * compiles.
748
+ *
410
749
  * This is a layer-returning function: bind the result to a `const` and reuse
411
750
  * that binding, or two provide sites mint two independent service instances.
412
751
  *
@@ -429,7 +768,7 @@ export declare class AppConfig {
429
768
  * });
430
769
  * ```
431
770
  */
432
- 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>;
771
+ static readonly layer: <Self, A, I, RR = never, S extends ConfigFileShape<A> = ConfigFileShape<A>>(tag: Context.Key<Self, S> & ([ConfigFileShape<A>] extends [S] ? unknown : never), options: AppConfigOptions<A, I, RR>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path | AppDirs | Xdg | RR>;
433
772
  }
434
773
  //#endregion
435
774
  export type { AppCacheOptions, AppConfigOptions, AppError, AppOptions, AppStoreOptions, AppTestOptions };
@@ -10,6 +10,17 @@ const badFilename = (context, filename) => {
10
10
  if (filename.length === 0) return /* @__PURE__ */ new Error(`${context}: \`filename\` must not be empty`);
11
11
  if (/[/\\]/.test(filename) || filename === "." || filename === "..") return /* @__PURE__ */ new Error(`${context}: \`filename\` must be a single path component, received ${JSON.stringify(filename)}`);
12
12
  };
13
+ /**
14
+ * A subdirectory is a relative path of single components, joined under the
15
+ * app's own directory. Every component obeys the filename rule, and the whole
16
+ * must not be absolute — so it can never climb out of the namespace directory.
17
+ */
18
+ const badSubdir = (context, subdir) => {
19
+ const reject = () => /* @__PURE__ */ new Error(`${context}: \`subdir\` must be a relative path of single path components, received ${JSON.stringify(subdir)}`);
20
+ if (subdir.length === 0) return /* @__PURE__ */ new Error(`${context}: \`subdir\` must not be empty`);
21
+ if (subdir.includes("\\") || subdir.startsWith("/")) return reject();
22
+ for (const component of subdir.split("/")) if (component.length === 0 || component === "." || component === "..") return reject();
23
+ };
13
24
 
14
25
  //#endregion
15
- export { badFilename };
26
+ export { badFilename, badSubdir };
@@ -0,0 +1,44 @@
1
+ import { badFilename, badSubdir } from "./filename.js";
2
+ import { AppDirs, AppDirsError } from "@effected/xdg";
3
+ import { Effect, FileSystem, Path } from "effect";
4
+
5
+ //#region src/internal/location.ts
6
+ /**
7
+ * Resolve a database file's location from the ambient `AppDirs`, guarding the
8
+ * path options, and creating NOTHING. The single derivation behind both the
9
+ * `location` statics and the layers, so a reported path and an opened path
10
+ * cannot disagree. A bad `filename` or `subdir` dies, naming `context`.
11
+ */
12
+ const resolveLocation = (context, kind, subdir, filename) => Effect.gen(function* () {
13
+ const invalid = badFilename(context, filename) ?? (subdir === void 0 ? void 0 : badSubdir(context, subdir));
14
+ if (invalid !== void 0) return yield* Effect.die(invalid);
15
+ const appDirs = yield* AppDirs;
16
+ const path = yield* Path.Path;
17
+ const base = appDirs.dirs[kind];
18
+ const dir = subdir === void 0 ? base : path.join(base, ...subdir.split("/"));
19
+ return {
20
+ kind,
21
+ dir,
22
+ file: path.join(dir, filename),
23
+ nested: subdir !== void 0
24
+ };
25
+ });
26
+ /**
27
+ * Create a resolved location's directory — the ensure-before-open half of every
28
+ * database layer here. The kind's own `ensure*` runs first; a subdirectory's
29
+ * `mkdir -p` then reports on the same typed `AppDirsError` channel, tagged with
30
+ * the directory kind it sits under, so a failure stays a value, never a defect.
31
+ */
32
+ const ensureLocation = (location) => Effect.gen(function* () {
33
+ const appDirs = yield* AppDirs;
34
+ yield* location.kind === "state" ? appDirs.ensureState : location.kind === "data" ? appDirs.ensureData : appDirs.ensureCache;
35
+ if (!location.nested) return;
36
+ yield* (yield* FileSystem.FileSystem).makeDirectory(location.dir, { recursive: true }).pipe(Effect.mapError((cause) => new AppDirsError({
37
+ directory: location.kind,
38
+ path: location.dir,
39
+ cause
40
+ })));
41
+ });
42
+
43
+ //#endregion
44
+ export { ensureLocation, resolveLocation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/app",
3
- "version": "0.20.0",
3
+ "version": "0.21.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,10 +39,10 @@
39
39
  "./package.json": "./package.json"
40
40
  },
41
41
  "peerDependencies": {
42
- "@effected/config-file": "^0.14.0",
42
+ "@effected/config-file": "^0.14.1",
43
43
  "@effected/glob": "^0.10.0",
44
44
  "@effected/jsonc": "^0.15.0",
45
- "@effected/store": "^0.12.0",
45
+ "@effected/store": "^0.13.0",
46
46
  "@effected/toml": "^0.11.0",
47
47
  "@effected/walker": "^0.15.0",
48
48
  "@effected/xdg": "^0.9.0",