@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 +105 -6
- package/AppCache.js +95 -19
- package/AppConfig.js +8 -0
- package/AppStore.js +112 -18
- package/README.md +92 -1
- package/index.d.ts +367 -28
- package/internal/filename.js +12 -1
- package/internal/location.js +44 -0
- package/package.json +3 -3
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,
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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 {
|
|
1
|
+
import { ensureLocation, resolveLocation } from "./internal/location.js";
|
|
2
2
|
import { Cache } from "@effected/store";
|
|
3
|
-
import {
|
|
4
|
-
import { Effect, Layer, Path } from "effect";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
5
4
|
|
|
6
5
|
//#region src/AppCache.ts
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const filename =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
30
|
-
* `Cache.layerSqlite` at `<
|
|
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 {
|
|
1
|
+
import { ensureLocation, resolveLocation } from "./internal/location.js";
|
|
2
2
|
import { Store } from "@effected/store";
|
|
3
|
-
import {
|
|
4
|
-
import { Effect, Layer, Path } from "effect";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
5
4
|
|
|
6
5
|
//#region src/AppStore.ts
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
21
|
-
*
|
|
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
|
|
29
|
-
* `Store.layerSqlite` at `<
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
32
|
-
* `Cache.layerSqlite` at `<
|
|
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
|
|
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,
|
|
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
|
|
66
|
-
*
|
|
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
|
|
74
|
-
* `Store.layerSqlite` at `<
|
|
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
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
|
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 };
|
package/internal/filename.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|