@effected/app 0.1.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 +77 -0
- package/AppCache.js +43 -0
- package/AppConfig.js +60 -0
- package/AppStore.js +45 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/index.d.ts +179 -0
- package/index.js +6 -0
- package/internal/filename.js +15 -0
- package/package.json +49 -0
- package/tsdoc-metadata.json +11 -0
package/App.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { AppCache } from "./AppCache.js";
|
|
2
|
+
import { AppStore } from "./AppStore.js";
|
|
3
|
+
import { Cache, Store } from "@effected/store";
|
|
4
|
+
import { AppDirs, Xdg, XdgPaths } from "@effected/xdg";
|
|
5
|
+
import { FileSystem, Layer, Path } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/App.ts
|
|
8
|
+
/** The synthetic default XDG environment `layerTest` resolves against. */
|
|
9
|
+
const testPaths = () => XdgPaths.make({
|
|
10
|
+
home: "/home/test",
|
|
11
|
+
configHome: "/home/test/.config",
|
|
12
|
+
dataHome: "/home/test/.local/share",
|
|
13
|
+
cacheHome: "/home/test/.cache",
|
|
14
|
+
stateHome: "/home/test/.local/state",
|
|
15
|
+
configDirs: ["/etc/xdg"],
|
|
16
|
+
dataDirs: ["/usr/local/share", "/usr/share"]
|
|
17
|
+
});
|
|
18
|
+
/**
|
|
19
|
+
* Build the application control plane: namespaced directories, the state
|
|
20
|
+
* database and the cache database, all pointed at the same place.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Composition is `AppDirs.layer(options)` `provideMerge` `Xdg.layer`, with the
|
|
24
|
+
* {@link AppStore} and {@link AppCache} glue `provideMerge`d over the result,
|
|
25
|
+
* so all four services come out and only `FileSystem` and `Path` stay in `R` —
|
|
26
|
+
* the two the consumer's platform layer supplies once, at the edge.
|
|
27
|
+
*
|
|
28
|
+
* `App.layer` always provides **both** databases: an application that wants
|
|
29
|
+
* only one composes `AppStore.layer` or `AppCache.layer` directly and never
|
|
30
|
+
* opens the other file. Passing no `cache` options still opens `cache.db`,
|
|
31
|
+
* because `CacheOptions` are all-optional and absence means defaults.
|
|
32
|
+
*
|
|
33
|
+
* This is a layer-returning function: bind the result to a `const` once and
|
|
34
|
+
* reuse that binding. Calling it inline at two provide sites opens two
|
|
35
|
+
* databases — two connections onto one file, two migration ledgers, and two
|
|
36
|
+
* independent `CacheEvent` PubSubs whose subscribers each see half the events.
|
|
37
|
+
*/
|
|
38
|
+
const layer = (options) => {
|
|
39
|
+
const { store, cache, ...dirOptions } = options;
|
|
40
|
+
const dirs = Layer.provideMerge(AppDirs.layer(dirOptions), Xdg.layer);
|
|
41
|
+
const databases = Layer.mergeAll(AppStore.layer(store), AppCache.layer(cache));
|
|
42
|
+
return Layer.provideMerge(databases, dirs);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The hermetic control plane: fixed XDG paths, `:memory:` databases, and the
|
|
46
|
+
* platform layers provided internally.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* `Xdg.layerFrom` over a synthetic default {@link XdgPaths}, `Store.layerTest`
|
|
50
|
+
* and `Cache.layerTest`, with `Path.layer` and `FileSystem.layerNoop` provided
|
|
51
|
+
* **internally** via `Layer.provide` — not merged into the output, not
|
|
52
|
+
* exposed. A consumer's first test needs no platform package at all.
|
|
53
|
+
*
|
|
54
|
+
* The documented limit: code paths that actually exercise `ensure*` **die**
|
|
55
|
+
* against `FileSystem.layerNoop` — it is a stub, not a working filesystem.
|
|
56
|
+
* `layerTest` is for testing logic that *uses* the control plane; a test of
|
|
57
|
+
* real directory behaviour uses {@link (App:variable).layer} with a
|
|
58
|
+
* temp-directory `HOME`.
|
|
59
|
+
*/
|
|
60
|
+
const layerTest = (options) => {
|
|
61
|
+
const dirs = Layer.provideMerge(AppDirs.layer({ namespace: options.namespace }), Xdg.layerFrom(options.paths ?? testPaths()));
|
|
62
|
+
const databases = Layer.mergeAll(Store.layerTest(options.store ?? { migrations: [] }), Cache.layerTest(options.cache));
|
|
63
|
+
return Layer.provide(Layer.mergeAll(databases, dirs), Layer.mergeAll(Path.layer, FileSystem.layerNoop({})));
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The application control plane: one layer wiring `Xdg`, `AppDirs`, `Store`
|
|
67
|
+
* and `Cache` to the same namespace.
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
const App = {
|
|
72
|
+
layer,
|
|
73
|
+
layerTest
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
export { App };
|
package/AppCache.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { badFilename } from "./internal/filename.js";
|
|
2
|
+
import { Cache } from "@effected/store";
|
|
3
|
+
import { AppDirs } from "@effected/xdg";
|
|
4
|
+
import { Effect, Layer, Path } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/AppCache.ts
|
|
7
|
+
/**
|
|
8
|
+
* Build the cache-directory database layer: `AppDirs.ensureCache`, then
|
|
9
|
+
* `Cache.layerSqlite` at `<cache dir>/<filename>`.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* The same ensure-before-open ordering as `AppStore.layer`, and it matters
|
|
13
|
+
* *more* here: the cache directory is the one an operator is most likely to
|
|
14
|
+
* have deleted between runs. `options` is optional because every
|
|
15
|
+
* `CacheOptions` field is.
|
|
16
|
+
*
|
|
17
|
+
* This is a layer-returning function: bind the result to a `const` and reuse
|
|
18
|
+
* that binding, or memoization by reference is lost and the database is
|
|
19
|
+
* opened twice.
|
|
20
|
+
*/
|
|
21
|
+
const layer = (options) => Layer.unwrap(Effect.gen(function* () {
|
|
22
|
+
const opts = options ?? {};
|
|
23
|
+
const filename = opts.filename ?? "cache.db";
|
|
24
|
+
const invalid = badFilename("AppCache.layer", filename);
|
|
25
|
+
if (invalid !== void 0) return yield* Effect.die(invalid);
|
|
26
|
+
const appDirs = yield* AppDirs;
|
|
27
|
+
const path = yield* Path.Path;
|
|
28
|
+
const cacheDir = yield* appDirs.ensureCache;
|
|
29
|
+
return Cache.layerSqlite({
|
|
30
|
+
...opts,
|
|
31
|
+
filename: path.join(cacheDir, filename)
|
|
32
|
+
});
|
|
33
|
+
}));
|
|
34
|
+
/**
|
|
35
|
+
* The cache-directory database glue: a TTL `Cache` whose file lives in the
|
|
36
|
+
* ambient `AppDirs` cache directory.
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
const AppCache = { layer };
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
export { AppCache };
|
package/AppConfig.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { badFilename } from "./internal/filename.js";
|
|
2
|
+
import { AppDirs, XdgConfig } from "@effected/xdg";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { ConfigFile, MergeStrategy } from "@effected/config-file";
|
|
5
|
+
|
|
6
|
+
//#region src/AppConfig.ts
|
|
7
|
+
/**
|
|
8
|
+
* Build the xdg-flavored config layer for a `ConfigFile.Service` class.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Wraps `ConfigFile.layer(tag, …)` with the resolver chain xdg documents, in
|
|
12
|
+
* xdg's documented order — `XdgConfig.resolver`, then
|
|
13
|
+
* `XdgConfig.nativeResolver` — and with `defaultPath:
|
|
14
|
+
* XdgConfig.savePath(filename)`, which fits config-file's infallible
|
|
15
|
+
* `defaultPath` slot without an `orDie` because xdg resolves at
|
|
16
|
+
* layer-construction time.
|
|
17
|
+
*
|
|
18
|
+
* **The namespace is never a parameter.** It is read from the ambient
|
|
19
|
+
* {@link AppDirs} service at layer build time, so it is typed exactly once, in
|
|
20
|
+
* `App.layer` — the two-strings drift where an app passes `"myapp"` to
|
|
21
|
+
* `App.layer` and `"my-app"` to its config preset cannot happen.
|
|
22
|
+
*
|
|
23
|
+
* This is a layer-returning function: bind the result to a `const` and reuse
|
|
24
|
+
* that binding, or two provide sites mint two independent service instances.
|
|
25
|
+
*/
|
|
26
|
+
const layer = (tag, options) => Layer.unwrap(Effect.gen(function* () {
|
|
27
|
+
const invalid = badFilename("AppConfig.layer", options.filename);
|
|
28
|
+
if (invalid !== void 0) return yield* Effect.die(invalid);
|
|
29
|
+
const appDirs = yield* AppDirs;
|
|
30
|
+
const resolvers = options.native === false ? [XdgConfig.resolver({ filename: options.filename })] : [XdgConfig.resolver({ filename: options.filename }), XdgConfig.nativeResolver({
|
|
31
|
+
namespace: appDirs.namespace,
|
|
32
|
+
filename: options.filename
|
|
33
|
+
})];
|
|
34
|
+
return ConfigFile.layer(tag, {
|
|
35
|
+
schema: options.schema,
|
|
36
|
+
codec: options.codec,
|
|
37
|
+
strategy: options.strategy ?? MergeStrategy.firstMatch(),
|
|
38
|
+
resolvers,
|
|
39
|
+
defaultPath: XdgConfig.savePath(options.filename),
|
|
40
|
+
...options.validate !== void 0 && { validate: options.validate },
|
|
41
|
+
...options.events !== void 0 && { events: options.events }
|
|
42
|
+
});
|
|
43
|
+
}));
|
|
44
|
+
/**
|
|
45
|
+
* The xdg-flavored `ConfigFile` preset: discovery through the app's XDG
|
|
46
|
+
* config search path, saves into the app's own config directory.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* A free-standing export, deliberately separate from anything that reaches
|
|
50
|
+
* the sqlite driver: `AppConfig` reaches `@effected/xdg` and
|
|
51
|
+
* `@effected/config-file` only, so a consumer who wants XDG-placed config
|
|
52
|
+
* files and no database imports it without pulling a SQLite driver into
|
|
53
|
+
* their graph.
|
|
54
|
+
*
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
const AppConfig = { layer };
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
export { AppConfig };
|
package/AppStore.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { badFilename } from "./internal/filename.js";
|
|
2
|
+
import { Store } from "@effected/store";
|
|
3
|
+
import { AppDirs } from "@effected/xdg";
|
|
4
|
+
import { Effect, Layer, Path } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/AppStore.ts
|
|
7
|
+
/**
|
|
8
|
+
* Build the state-directory database layer: `AppDirs.ensureState`, then
|
|
9
|
+
* `Store.layerSqlite` at `<state dir>/<filename>`.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* The ensure-before-open ordering is the load-bearing glue.
|
|
13
|
+
* `SqliteClient.layer` has no error channel and **defects** on a missing
|
|
14
|
+
* parent directory; `AppDirs.ensureState` is a `mkdir -p` on a **typed**
|
|
15
|
+
* `AppDirsError` channel. Running the ensure inside `Layer.unwrap`, before the
|
|
16
|
+
* store layer is built, converts a defect surface into a typed one — "the
|
|
17
|
+
* state directory could not be created" is an expected, recoverable boundary
|
|
18
|
+
* failure and it stays on `E`. Nothing is `orDie`d.
|
|
19
|
+
*
|
|
20
|
+
* This is a layer-returning function: bind the result to a `const` and reuse
|
|
21
|
+
* that binding, or memoization by reference is lost and the database is
|
|
22
|
+
* opened twice.
|
|
23
|
+
*/
|
|
24
|
+
const layer = (options) => Layer.unwrap(Effect.gen(function* () {
|
|
25
|
+
const filename = options.filename ?? "store.db";
|
|
26
|
+
const invalid = badFilename("AppStore.layer", filename);
|
|
27
|
+
if (invalid !== void 0) return yield* Effect.die(invalid);
|
|
28
|
+
const appDirs = yield* AppDirs;
|
|
29
|
+
const path = yield* Path.Path;
|
|
30
|
+
const stateDir = yield* appDirs.ensureState;
|
|
31
|
+
return Store.layerSqlite({
|
|
32
|
+
...options,
|
|
33
|
+
filename: path.join(stateDir, filename)
|
|
34
|
+
});
|
|
35
|
+
}));
|
|
36
|
+
/**
|
|
37
|
+
* The state-directory database glue: a migrated SQLite `Store` whose file
|
|
38
|
+
* lives in the ambient `AppDirs` state directory.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
const AppStore = { layer };
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { AppStore };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# @effected/app
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/app)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
The application control plane for Effect. `App.layer` gives an application its XDG-namespaced directories, a migrated SQLite state database, a TTL cache and — through `AppConfig.layer` — a config file, all pointed at the same place, with the namespace typed exactly once. It is a composition over [`@effected/xdg`](../xdg), [`@effected/store`](../store) and [`@effected/config-file`](../config-file), and nothing else.
|
|
9
|
+
|
|
10
|
+
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
|
+
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
12
|
+
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
|
+
> exactly the ones the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Why @effected/app
|
|
23
|
+
|
|
24
|
+
Every application built on this kit writes the same forty lines of wiring, and it is the kind of wiring that looks right and is wrong. `@effected/xdg` resolves where a namespace's directories are. `@effected/store` opens a database at a path. Between them sits an ordering nobody thinks about until it bites: `SqliteClient.layer` has no error channel and **defects** on a missing parent directory, so the directory must be ensured *before* the store layer is built, or the failure arrives as a defect that nothing downstream can catch typed.
|
|
25
|
+
|
|
26
|
+
The other half is the namespace itself. An application names it for its directories, then names it again for its config file, and the two strings drift — `"myapp"` here, `"my-app"` there — and now it reads config from a directory nothing else in the process ever writes to.
|
|
27
|
+
|
|
28
|
+
This package is the composition that gets both right, and that is all it is. It owns **no domain logic**: no service, no schema, no error class, and it re-exports nothing. The entire surface is layer factories, one config preset and one type alias. If a change here wants a `Context.Service`, that is the signal the change belongs in one of the three packages beneath it.
|
|
29
|
+
|
|
30
|
+
**Nothing may depend on `@effected/app`.** A library taking an application control plane as a dependency would drag a SQLite driver into its own consumers' trees. This is the package an application composes at its edge — and the only one in the kit whose docs show where OpenTelemetry goes.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install @effected/app effect @effected/xdg @effected/store @effected/config-file
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm add @effected/app effect @effected/xdg @effected/store @effected/config-file
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Node.js >=24.11.0.
|
|
43
|
+
|
|
44
|
+
There are **no runtime dependencies**. `effect` v4 is a peer dependency, and so are `@effected/xdg`, `@effected/store` and `@effected/config-file` — which is load-bearing rather than incidental. Each of the three appears in this package's public signature types, so a second copy of any of them in your graph would mint two distinct service tags for one concept and the layer would silently fail to satisfy the requirement. Single copies are the point, and that is exactly what a peer declares. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not.
|
|
45
|
+
|
|
46
|
+
The package is integrated tier **by inheritance, not by anything it does**: `@effected/store` reaches `@effect/sql-sqlite-node`, and that propagates. It performs no IO the three packages beneath it do not already perform.
|
|
47
|
+
|
|
48
|
+
Creating directories needs a `FileSystem` and a `Path` implementation, provided once at the edge — from `@effect/platform-node` on Node.
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
A config file, a cache and a state database, over one platform import:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { App, AppConfig } from "@effected/app";
|
|
56
|
+
import { ConfigFile, JsonCodec } from "@effected/config-file";
|
|
57
|
+
import { Cache, Store } from "@effected/store";
|
|
58
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
59
|
+
import { Effect, Layer, Schema } from "effect";
|
|
60
|
+
|
|
61
|
+
class Settings extends Schema.Class<Settings>("Settings")({
|
|
62
|
+
registry: Schema.String,
|
|
63
|
+
concurrency: Schema.Number,
|
|
64
|
+
}) {}
|
|
65
|
+
class SettingsFile extends ConfigFile.Service<SettingsFile, Settings>()("myapp/Settings") {}
|
|
66
|
+
|
|
67
|
+
const migrations = [
|
|
68
|
+
{ id: 1, name: "runs", up: (sql) => sql`CREATE TABLE runs (id TEXT PRIMARY KEY, at TEXT)` },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// Bound once, to a const — see Memoization below. This is the whole control plane:
|
|
72
|
+
// XDG dirs for "myapp", store.db in the state dir, cache.db in the cache dir.
|
|
73
|
+
const AppLive = App.layer({ namespace: "myapp", store: { migrations }, cache: { maxEntries: 500 } });
|
|
74
|
+
|
|
75
|
+
const ConfigLive = AppConfig.layer(SettingsFile, {
|
|
76
|
+
filename: "config.json", // no namespace: it comes from AppLive's AppDirs
|
|
77
|
+
schema: Settings,
|
|
78
|
+
codec: JsonCodec,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const MainLive = ConfigLive.pipe(
|
|
82
|
+
Layer.provideMerge(AppLive),
|
|
83
|
+
Layer.provide(NodeServices.layer), // the one place a platform is named
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const main = Effect.gen(function* () {
|
|
87
|
+
const settings = yield* (yield* SettingsFile).load;
|
|
88
|
+
const store = yield* Store;
|
|
89
|
+
const cache = yield* Cache;
|
|
90
|
+
|
|
91
|
+
yield* store.client`INSERT INTO runs (id, at) VALUES (${crypto.randomUUID()}, datetime())`;
|
|
92
|
+
yield* cache.set({ key: "last-registry", value: new TextEncoder().encode(settings.registry) });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
NodeRuntime.runMain(main.pipe(Effect.provide(MainLive)));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Four services, one platform import, one namespace typed once, and every failure on the typed channel. That is the whole package.
|
|
99
|
+
|
|
100
|
+
## Where the files land
|
|
101
|
+
|
|
102
|
+
`App.layer` ensures each directory before it opens anything in it. With `namespace: "myapp"` and the default `native: false`, the XDG rules apply on every platform:
|
|
103
|
+
|
|
104
|
+
| What | Directory | Default file |
|
|
105
|
+
| ---- | --------- | ------------ |
|
|
106
|
+
| State database | `$XDG_STATE_HOME/myapp` — `~/.local/state/myapp` | `store.db` |
|
|
107
|
+
| Cache database | `$XDG_CACHE_HOME/myapp` — `~/.cache/myapp` | `cache.db` |
|
|
108
|
+
| Config file | `$XDG_CONFIG_HOME/myapp` — `~/.config/myapp` | your `filename` |
|
|
109
|
+
|
|
110
|
+
`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.
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
## The namespace is typed once
|
|
115
|
+
|
|
116
|
+
`AppConfig.layer` takes no namespace. It reads one from the ambient `AppDirs` service at layer build time, so the namespace is named exactly once — in `App.layer` — and the two-strings drift cannot happen. Anything that can be derived is not asked for.
|
|
117
|
+
|
|
118
|
+
What it does take is a **codec, required**, never inferred from the filename's extension. Inferring one would hard-code a *format* choice into a composition layer, which is not this package's decision to make; and the named import is also what keeps the other three parsing engines out of your bundle.
|
|
119
|
+
|
|
120
|
+
`AppConfig` lives in its own module and reaches `@effected/xdg` and `@effected/config-file` **only** — never `@effected/store`. An application that wants XDG-placed config files and no database imports `AppConfig` alone, and no SQLite driver enters its graph. `App`, `AppStore` and `AppCache` are the exports that reach a database, and keeping the two graphs apart is why there is no `App = { … }` namespace object here.
|
|
121
|
+
|
|
122
|
+
Its `native` option defaults to **`true`** — the opposite of `AppDirsOptions.native`, and the asymmetry is deliberate. *Creating* a native directory commits an application to a location, so it is opt-in; *probing* one for a config file the user already put there costs a `stat` that finds nothing, so it is opt-out. Reading `~/Library/Application Support` is a courtesy; writing there uninvited is not.
|
|
123
|
+
|
|
124
|
+
## Ensure before open
|
|
125
|
+
|
|
126
|
+
The one thing this package actually claims. `AppStore.layer` yields `AppDirs`, runs `ensureState`, joins the path, and only then hands it to `Store.layerSqlite`; `AppCache.layer` does the same over `ensureCache`. That ordering converts a defect surface into a typed one — `AppDirs.ensure*` is a `mkdir -p` on a **typed** `AppDirsError` channel, so "the state directory could not be created" arrives as a recoverable boundary failure rather than a die.
|
|
127
|
+
|
|
128
|
+
Nothing is `orDie`d to make a signature tidier. A regression test pins an unwritable ancestor to a typed failure and watches for a die.
|
|
129
|
+
|
|
130
|
+
`App.layer` always provides **both** databases. An application that wants only one composes `AppStore.layer` or `AppCache.layer` directly and never opens the other file. The honest consequence: passing no `cache` options **still opens `cache.db`**, because every `CacheOptions` field is optional and absence means defaults, not absence.
|
|
131
|
+
|
|
132
|
+
## Memoization: bind the layer to a const
|
|
133
|
+
|
|
134
|
+
Every export here is a **parameterized layer factory**, and Effect memoizes layers **by reference**. Each call to `App.layer(…)` builds a new one.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const AppLive = App.layer({ namespace: "myapp", store: { migrations } }); // once
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
## Testing: one line, no platform package
|
|
143
|
+
|
|
144
|
+
`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:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { App } from "@effected/app";
|
|
148
|
+
import { layer } from "@effect/vitest";
|
|
149
|
+
import { Effect } from "effect";
|
|
150
|
+
|
|
151
|
+
layer(App.layerTest({ namespace: "myapp" }))("app", (it) => {
|
|
152
|
+
it.effect("stores state", () =>
|
|
153
|
+
Effect.gen(function* () {
|
|
154
|
+
// Store and Cache are here, in memory, hermetic.
|
|
155
|
+
}));
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The documented limit, stated up front so nobody finds it in a debugger: code paths that actually exercise `ensure*` **die** against the stub filesystem `layerTest` provides. It is for testing logic that *uses* the control plane. Real directory behavior is tested through `App.layer` against a temp-directory `HOME`, which is what this package's own integration suite does.
|
|
160
|
+
|
|
161
|
+
## Errors
|
|
162
|
+
|
|
163
|
+
`AppError` is a **type-only** alias — the copy-pasteable union for the `catchTags` block at the application edge. It defines nothing: every tag in it is raised, and documented, by the package beneath that owns the operation, and each flows through unwrapped. A `StoreMigrationError` that reaches your handler still carries the migration's `id`, `name` and `direction`.
|
|
164
|
+
|
|
165
|
+
| Tag | Raised by | Means |
|
|
166
|
+
| --- | --------- | ----- |
|
|
167
|
+
| `XdgEnvError` | [`@effected/xdg`](../xdg) | `$HOME` is not set — the one environment failure there is. |
|
|
168
|
+
| `AppDirsError` | [`@effected/xdg`](../xdg) | A directory could not be created. Check permissions. |
|
|
169
|
+
| `StoreError` | [`@effected/store`](../store) | The state database's own SQL failed — ledger bookkeeping, or the queries around a migration. |
|
|
170
|
+
| `StoreMigrationError` | [`@effected/store`](../store) | One of your migrations failed. Carries `direction`, `id` and `name`. |
|
|
171
|
+
| `CacheError` | [`@effected/store`](../store) | A cache operation's SQL failed. A cache is a cache — falling back to the origin is usually right. |
|
|
172
|
+
|
|
173
|
+
Wiring mistakes are **defects**, not errors: a `filename` that is not a single path component dies at layer construction, as does a `namespace` that would escape `$HOME`. They can only come from code.
|
|
174
|
+
|
|
175
|
+
## Telemetry goes at the app edge
|
|
176
|
+
|
|
177
|
+
This package emits **no spans of its own**, deliberately. Every fallible operation in the glue is already spanned by the package that owns it — `AppDirs.ensure*` by xdg, migrations and every `Cache` method by store, every `ConfigFile` method by config-file. A span here would be a span around another package's span.
|
|
178
|
+
|
|
179
|
+
Every library in this kit is telemetry-agnostic and none of them import `@effect/opentelemetry`. **Applications do, exactly once, at the top:**
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { NodeSdk } from "@effect/opentelemetry";
|
|
183
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
184
|
+
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
185
|
+
|
|
186
|
+
const TelemetryLive = NodeSdk.layer(() => ({
|
|
187
|
+
resource: { serviceName: "myapp" },
|
|
188
|
+
spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter()),
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
const MainLive = ConfigLive.pipe(
|
|
192
|
+
Layer.provideMerge(AppLive),
|
|
193
|
+
Layer.provide(NodeServices.layer),
|
|
194
|
+
Layer.provide(TelemetryLive), // composed once, beneath everything
|
|
195
|
+
);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Provide it beneath the stack and every span the kit's packages already emit — directory creation, migrations, cache reads, config loads — arrives at your collector. Nothing in the libraries changes, because a library that chose an exporter for you would be making an application's decision.
|
|
199
|
+
|
|
200
|
+
## Features
|
|
201
|
+
|
|
202
|
+
- `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.
|
|
203
|
+
- `App.layerTest` — the same four services, hermetic: synthetic XDG paths, `:memory:` databases, no platform package required.
|
|
204
|
+
- `AppStore.layer` / `AppCache.layer` — the state-directory and cache-directory databases on their own, each ensuring its directory before it opens the file.
|
|
205
|
+
- `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.
|
|
206
|
+
- `AppError` — the type-only union of everything the control plane can fail with, for the `catchTags` block at the edge.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
[MIT](LICENSE)
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Cache, CacheError, CacheOptions, Store, StoreError, StoreMigrationError, StoreOptions } from "@effected/store";
|
|
2
|
+
import { AppDirs, AppDirsError, AppDirsOptions, Xdg, XdgEnvError, XdgPaths } from "@effected/xdg";
|
|
3
|
+
import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
|
|
4
|
+
import { ConfigCodec, ConfigEvents, ConfigEventsShape, ConfigFileShape, ConfigValidationError, MergeStrategy } from "@effected/config-file";
|
|
5
|
+
//#region src/AppCache.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Options for {@link (AppCache:variable).layer}.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
interface AppCacheOptions extends CacheOptions {
|
|
12
|
+
/**
|
|
13
|
+
* File name within the app's cache directory. Default `"cache.db"`.
|
|
14
|
+
*
|
|
15
|
+
* @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
|
|
18
|
+
* construction — it can only come from code, never from user input.
|
|
19
|
+
*/
|
|
20
|
+
readonly filename?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The cache-directory database glue: a TTL `Cache` whose file lives in the
|
|
24
|
+
* ambient `AppDirs` cache directory.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
declare const AppCache: {
|
|
29
|
+
readonly layer: (options?: AppCacheOptions) => Layer.Layer<Cache, AppDirsError | CacheError, AppDirs | Path.Path>;
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/AppStore.d.ts
|
|
33
|
+
/**
|
|
34
|
+
* Options for {@link (AppStore:variable).layer}.
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
interface AppStoreOptions extends StoreOptions {
|
|
39
|
+
/**
|
|
40
|
+
* File name within the app's state directory. Default `"store.db"`.
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* A single path component. An empty name, or one containing a separator,
|
|
44
|
+
* would escape the namespace directory, so it **dies** at layer
|
|
45
|
+
* construction — it can only come from code, never from user input.
|
|
46
|
+
*/
|
|
47
|
+
readonly filename?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The state-directory database glue: a migrated SQLite `Store` whose file
|
|
51
|
+
* lives in the ambient `AppDirs` state directory.
|
|
52
|
+
*
|
|
53
|
+
* @public
|
|
54
|
+
*/
|
|
55
|
+
declare const AppStore: {
|
|
56
|
+
readonly layer: (options: AppStoreOptions) => Layer.Layer<Store, AppDirsError | StoreError | StoreMigrationError, AppDirs | Path.Path>;
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/App.d.ts
|
|
60
|
+
/**
|
|
61
|
+
* Everything that can come out of the control plane, for the application
|
|
62
|
+
* edge's `catchTags` block.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* A **type-only** alias — it erases, so it costs nothing in the module graph
|
|
66
|
+
* and creates no runtime binding to tree-shake around. It is a convenience
|
|
67
|
+
* over the constituent packages' errors, not a new error model: every tag in
|
|
68
|
+
* it is defined and documented by the package that raises it, and each flows
|
|
69
|
+
* through unwrapped with its structure intact.
|
|
70
|
+
*
|
|
71
|
+
* @public
|
|
72
|
+
*/
|
|
73
|
+
type AppError = XdgEnvError | AppDirsError | StoreError | StoreMigrationError | CacheError;
|
|
74
|
+
/**
|
|
75
|
+
* Options for {@link (App:variable).layer}.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* The `AppDirsOptions` fields — `namespace`, `native`, `fallbackDir`, `dirs` —
|
|
79
|
+
* are pass-through: they mean exactly what `@effected/xdg` documents,
|
|
80
|
+
* including the five-level precedence ladder.
|
|
81
|
+
*
|
|
82
|
+
* @public
|
|
83
|
+
*/
|
|
84
|
+
interface AppOptions extends AppDirsOptions {
|
|
85
|
+
/** The state database's options; `migrations` is the consumer's schema. */
|
|
86
|
+
readonly store: AppStoreOptions;
|
|
87
|
+
/** The cache database's options. Absence means defaults, not absence. */
|
|
88
|
+
readonly cache?: AppCacheOptions;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Options for {@link (App:variable).layerTest}.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
interface AppTestOptions {
|
|
96
|
+
/** The application namespace — one path component. */
|
|
97
|
+
readonly namespace: string;
|
|
98
|
+
/** Pin real XDG paths; defaults to a synthetic set under a fake home. */
|
|
99
|
+
readonly paths?: XdgPaths;
|
|
100
|
+
/** The in-memory state database's options. Default: no migrations. */
|
|
101
|
+
readonly store?: StoreOptions;
|
|
102
|
+
/** The in-memory cache's options. */
|
|
103
|
+
readonly cache?: CacheOptions;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The application control plane: one layer wiring `Xdg`, `AppDirs`, `Store`
|
|
107
|
+
* and `Cache` to the same namespace.
|
|
108
|
+
*
|
|
109
|
+
* @public
|
|
110
|
+
*/
|
|
111
|
+
declare const App: {
|
|
112
|
+
readonly layer: (options: AppOptions) => Layer.Layer<Xdg | AppDirs | Store | Cache, AppError, FileSystem.FileSystem | Path.Path>;
|
|
113
|
+
readonly layerTest: (options: AppTestOptions) => Layer.Layer<Xdg | AppDirs | Store | Cache, AppError>;
|
|
114
|
+
};
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/AppConfig.d.ts
|
|
117
|
+
/**
|
|
118
|
+
* Options for {@link (AppConfig:variable).layer}.
|
|
119
|
+
*
|
|
120
|
+
* @public
|
|
121
|
+
*/
|
|
122
|
+
interface AppConfigOptions<A, I> {
|
|
123
|
+
/**
|
|
124
|
+
* The config file's name within the app's config directory.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* No default — a config filename is the consumer's decision. A single path
|
|
128
|
+
* component: an empty name, or one containing a separator, **dies** at
|
|
129
|
+
* layer construction.
|
|
130
|
+
*/
|
|
131
|
+
readonly filename: string;
|
|
132
|
+
/** The schema every discovered document is decoded through. */
|
|
133
|
+
readonly schema: Schema.Codec<A, I>;
|
|
134
|
+
/**
|
|
135
|
+
* How file content becomes an unknown document, and back.
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* Required — never inferred, never defaulted. Defaulting it, or inferring
|
|
139
|
+
* one from `filename`'s extension, would hard-code a *format* choice into a
|
|
140
|
+
* composition layer, which is not this package's decision to make. The
|
|
141
|
+
* named import (`JsonCodec`, `TomlCodec`, …) is also what keeps the other
|
|
142
|
+
* engines out of the consumer's bundle.
|
|
143
|
+
*/
|
|
144
|
+
readonly codec: ConfigCodec;
|
|
145
|
+
/** How several discovered sources become one value. Default `MergeStrategy.firstMatch`. */
|
|
146
|
+
readonly strategy?: MergeStrategy<A>;
|
|
147
|
+
/** An optional caller-supplied check run after schema decoding. */
|
|
148
|
+
readonly validate?: (value: A) => Effect.Effect<A, ConfigValidationError>;
|
|
149
|
+
/** The opt-in event hook. Pass the `ConfigEvents` class itself. */
|
|
150
|
+
readonly events?: Context.Key<ConfigEvents, ConfigEventsShape>;
|
|
151
|
+
/**
|
|
152
|
+
* Probe the OS-native config directory as a fallback. Defaults to `true`.
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* The native probe sits **after** the XDG resolver, so an existing
|
|
156
|
+
* `~/.config/<app>` still beats the native directory; on Linux it resolves
|
|
157
|
+
* to nothing and never touches the filesystem. Pass `false` to drop it.
|
|
158
|
+
*/
|
|
159
|
+
readonly native?: boolean;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The xdg-flavored `ConfigFile` preset: discovery through the app's XDG
|
|
163
|
+
* config search path, saves into the app's own config directory.
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* A free-standing export, deliberately separate from anything that reaches
|
|
167
|
+
* the sqlite driver: `AppConfig` reaches `@effected/xdg` and
|
|
168
|
+
* `@effected/config-file` only, so a consumer who wants XDG-placed config
|
|
169
|
+
* files and no database imports it without pulling a SQLite driver into
|
|
170
|
+
* their graph.
|
|
171
|
+
*
|
|
172
|
+
* @public
|
|
173
|
+
*/
|
|
174
|
+
declare const AppConfig: {
|
|
175
|
+
readonly layer: <Self, A, I>(tag: Context.Key<Self, ConfigFileShape<A>>, options: AppConfigOptions<A, I>) => Layer.Layer<Self, never, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
|
|
176
|
+
};
|
|
177
|
+
//#endregion
|
|
178
|
+
export { App, AppCache, type AppCacheOptions, AppConfig, type AppConfigOptions, type AppError, type AppOptions, AppStore, type AppStoreOptions, type AppTestOptions };
|
|
179
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/internal/filename.ts
|
|
2
|
+
/**
|
|
3
|
+
* A filename is one path component. Anything else escapes the app's own
|
|
4
|
+
* directory, so it dies rather than resolving somewhere surprising — the same
|
|
5
|
+
* wiring-defect rule xdg applies to `namespace`. Shared by every module with
|
|
6
|
+
* a `filename` option, so a new rejected shape is added here once; the
|
|
7
|
+
* test-side mirror is `__test__/filenameGuard.ts`.
|
|
8
|
+
*/
|
|
9
|
+
const badFilename = (context, filename) => {
|
|
10
|
+
if (filename.length === 0) return /* @__PURE__ */ new Error(`${context}: \`filename\` must not be empty`);
|
|
11
|
+
if (/[/\\]/.test(filename) || filename === "." || filename === "..") return /* @__PURE__ */ new Error(`${context}: \`filename\` must be a single path component, received ${JSON.stringify(filename)}`);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { badFilename };
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effected/app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
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
|
+
"keywords": [
|
|
7
|
+
"app",
|
|
8
|
+
"xdg",
|
|
9
|
+
"sqlite",
|
|
10
|
+
"store",
|
|
11
|
+
"cache",
|
|
12
|
+
"config",
|
|
13
|
+
"effect",
|
|
14
|
+
"effected"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/app#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/spencerbeggs/effected/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/spencerbeggs/effected.git",
|
|
23
|
+
"directory": "packages/app"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "C. Spencer Beggs",
|
|
28
|
+
"email": "spencer@beggs.codes",
|
|
29
|
+
"url": "https://spencerbeg.gs"
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"type": "module",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./index.d.ts",
|
|
36
|
+
"import": "./index.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@effected/config-file": "0.1.0",
|
|
42
|
+
"@effected/store": "0.1.0",
|
|
43
|
+
"@effected/xdg": "0.1.0",
|
|
44
|
+
"effect": "4.0.0-beta.98"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=24.11.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.58.9"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|