@crustjs/effect 0.0.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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +126 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chenxin Yan
|
|
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,51 @@
|
|
|
1
|
+
# @crustjs/effect
|
|
2
|
+
|
|
3
|
+
Effect.ts v4 adaptor for Crust: write Command Actions and Contexts in Effect idiom while Crust stays the runtime.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @crustjs/effect@next effect@4.0.0-rc.115
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`effect` (4.x prerelease) and `@crustjs/core` are peer dependencies. Install the exact `effect` version this package was tested against; the adaptor is published under the `next` dist-tag.
|
|
12
|
+
|
|
13
|
+
## Quick example
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Crust, defineContext } from "@crustjs/core";
|
|
17
|
+
import { handler, layer, service } from "@crustjs/effect";
|
|
18
|
+
import { Context, Effect, Layer } from "effect";
|
|
19
|
+
|
|
20
|
+
class Db extends Context.Service<Db, { readonly query: (sql: string) => string }>()("app/Db") {}
|
|
21
|
+
|
|
22
|
+
const db = layer(
|
|
23
|
+
"db",
|
|
24
|
+
Layer.effect(
|
|
25
|
+
Db,
|
|
26
|
+
Effect.acquireRelease(
|
|
27
|
+
Effect.sync(() => ({ query: (sql) => `rows for ${sql}` })),
|
|
28
|
+
() => Effect.sync(() => console.log("closed")),
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
const config = defineContext("config", () => ({ limit: 10 }));
|
|
33
|
+
|
|
34
|
+
await new Crust("app")
|
|
35
|
+
.provide(db(), config())
|
|
36
|
+
.args({ name: "table", type: "string", required: true })
|
|
37
|
+
.action(
|
|
38
|
+
handler(function* ({ args }) {
|
|
39
|
+
const d = yield* Db;
|
|
40
|
+
const cfg = yield* service(config);
|
|
41
|
+
console.log(d.query(`select * from ${args.table} limit ${cfg.limit}`));
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
.execute();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Every `layer()` on the command path is built when the handler starts and released by Crust's cleanup; plain Contexts stay lazy. If `layer`/`handler` clash with names in your module, use `import * as CrustEffect from "@crustjs/effect"`.
|
|
48
|
+
|
|
49
|
+
## Documentation
|
|
50
|
+
|
|
51
|
+
Full docs: [crustjs.com/docs/modules/effect](https://crustjs.com/docs/modules/effect)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { AnyContextFactory, ContextFactory, CrustCommandContext, CrustError, CrustErrorCode, CrustErrorDetails, FactoryValueOf } from "@crustjs/core";
|
|
2
|
+
import { Cause, Context, Effect, Exit, Layer } from "effect";
|
|
3
|
+
//#region src/errors.d.ts
|
|
4
|
+
/** Fields shared by every tagged Crust error; `cause` keeps the original for rethrow. */
|
|
5
|
+
interface CrustErrorFields<C extends CrustErrorCode> {
|
|
6
|
+
readonly message: string;
|
|
7
|
+
readonly details: CrustErrorDetails<C>;
|
|
8
|
+
readonly cause: CrustError<C>;
|
|
9
|
+
}
|
|
10
|
+
type TaggedCrustError<Tag extends string, C extends CrustErrorCode> = new (fields: CrustErrorFields<C>) => Cause.YieldableError & {
|
|
11
|
+
readonly _tag: Tag;
|
|
12
|
+
} & CrustErrorFields<C>;
|
|
13
|
+
declare const DefinitionBase: TaggedCrustError<"CrustDefinitionError", "DEFINITION">;
|
|
14
|
+
declare const ValidationBase: TaggedCrustError<"CrustValidationError", "VALIDATION">;
|
|
15
|
+
declare const ParseBase: TaggedCrustError<"CrustParseError", "PARSE">;
|
|
16
|
+
declare const CommandNotFoundBase: TaggedCrustError<"CrustCommandNotFoundError", "COMMAND_NOT_FOUND">;
|
|
17
|
+
export declare class CrustDefinitionError extends DefinitionBase {}
|
|
18
|
+
export declare class CrustValidationError extends ValidationBase {}
|
|
19
|
+
export declare class CrustParseError extends ParseBase {}
|
|
20
|
+
export declare class CrustCommandNotFoundError extends CommandNotFoundBase {}
|
|
21
|
+
type CrustTaggedError = CrustDefinitionError | CrustValidationError | CrustParseError | CrustCommandNotFoundError;
|
|
22
|
+
/** Wrap a caught {@link CrustError} in the tagged class for its `code`. */
|
|
23
|
+
export declare function fromCrustError(error: CrustError): CrustTaggedError;
|
|
24
|
+
/**
|
|
25
|
+
* Lift a throwing thunk or promise into an Effect. A thrown `CrustError`
|
|
26
|
+
* fails with its tagged wrapper, an `AbortError` interrupts the fiber, and
|
|
27
|
+
* anything else is a defect.
|
|
28
|
+
*/
|
|
29
|
+
export declare function tryCrust<A>(evaluate: () => A | PromiseLike<A>): Effect.Effect<A, CrustTaggedError>;
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/layer.d.ts
|
|
32
|
+
declare const LayerBrand: unique symbol;
|
|
33
|
+
/** A built Effect `Context.Context` produced by {@link layer}; the brand is type-only. */
|
|
34
|
+
type LayerValue<S> = Context.Context<S> & {
|
|
35
|
+
readonly [LayerBrand]: true;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Turn one fully composed Layer into a Crust Context whose value is the built
|
|
39
|
+
* `Context.Context`. Every `layer()` on the command path is built when a
|
|
40
|
+
* `handler()` action starts and released, in reverse order, by Crust's
|
|
41
|
+
* invocation cleanup.
|
|
42
|
+
*/
|
|
43
|
+
export declare function layer<Name extends string, ROut, E>(name: Name, live: Layer.Layer<ROut, E>): ContextFactory<Name, void, LayerValue<ROut>>;
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/handler.d.ts
|
|
46
|
+
type ActionInput = CrustCommandContext<any, any, any>;
|
|
47
|
+
declare const HandlerInputBase: Context.ServiceClass<HandlerInput, "@crustjs/effect/HandlerInput", ActionInput>;
|
|
48
|
+
/** The invocation input, provided to every {@link handler} program so {@link service} can pull plain Contexts. */
|
|
49
|
+
declare class HandlerInput extends HandlerInputBase {}
|
|
50
|
+
/**
|
|
51
|
+
* Union of the services provided by the {@link layer} Contexts in an action
|
|
52
|
+
* input's `ctx` bag. Open-name bags (`Record<string, …>`) provide nothing:
|
|
53
|
+
* their entries cannot be tied to a layer on the path.
|
|
54
|
+
*/
|
|
55
|
+
type ServicesOf<Input> = Input extends {
|
|
56
|
+
readonly ctx: infer Bag;
|
|
57
|
+
} ? string extends keyof Bag ? never : { [K in keyof Bag]-?: Bag[K] extends Promise<LayerValue<infer S>> ? S : never; }[keyof Bag] : never;
|
|
58
|
+
/**
|
|
59
|
+
* Adapt an Effect program to a Crust action. Every {@link layer} on the
|
|
60
|
+
* command path is built up front and its services provided; `ctx` inference
|
|
61
|
+
* is unchanged. A failure rethrows the original error so `execute()` renders
|
|
62
|
+
* it unchanged, and interruption rethrows an `AbortError` so cancellation
|
|
63
|
+
* exits with 130.
|
|
64
|
+
*/
|
|
65
|
+
export declare function handler<Input extends ActionInput, Out, E>(fn: (input: Input) => Effect.Effect<Out, E, ServicesOf<Input> | HandlerInput>): (input: Input) => Promise<Out>;
|
|
66
|
+
export declare function handler<Input extends ActionInput, Eff extends Effect.Effect<any, any, ServicesOf<Input> | HandlerInput>, Out>(fn: (input: Input) => Generator<Eff, Out, never>): (input: Input) => Promise<Out>;
|
|
67
|
+
/**
|
|
68
|
+
* Pull a plain Crust Context by factory from inside a {@link handler} program.
|
|
69
|
+
* Lazy like `ctx.<name>`; a name absent from the command path fails with
|
|
70
|
+
* Core's missing-context error as a `CrustDefinitionError`.
|
|
71
|
+
*/
|
|
72
|
+
export declare function service<F extends AnyContextFactory>(factory: F): Effect.Effect<FactoryValueOf<F>, CrustTaggedError, HandlerInput>;
|
|
73
|
+
//#endregion
|
|
74
|
+
export type { CrustTaggedError, LayerValue, ServicesOf };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CrustError, contextSources, defineContext } from "@crustjs/core";
|
|
2
|
+
import { Cause, Context, Data, Effect, Exit, Layer, Scope } from "effect";
|
|
3
|
+
//#region src/errors.ts
|
|
4
|
+
const tagged = (tag) => Data.TaggedError(tag);
|
|
5
|
+
const DefinitionBase = tagged("CrustDefinitionError");
|
|
6
|
+
const ValidationBase = tagged("CrustValidationError");
|
|
7
|
+
const ParseBase = tagged("CrustParseError");
|
|
8
|
+
const CommandNotFoundBase = tagged("CrustCommandNotFoundError");
|
|
9
|
+
var CrustDefinitionError = class extends DefinitionBase {};
|
|
10
|
+
var CrustValidationError = class extends ValidationBase {};
|
|
11
|
+
var CrustParseError = class extends ParseBase {};
|
|
12
|
+
var CrustCommandNotFoundError = class extends CommandNotFoundBase {};
|
|
13
|
+
const fields = (error) => ({
|
|
14
|
+
message: error.message,
|
|
15
|
+
details: error.details,
|
|
16
|
+
cause: error
|
|
17
|
+
});
|
|
18
|
+
/** Wrap a caught {@link CrustError} in the tagged class for its `code`. */
|
|
19
|
+
function fromCrustError(error) {
|
|
20
|
+
if (error.is("DEFINITION")) return new CrustDefinitionError(fields(error));
|
|
21
|
+
if (error.is("VALIDATION")) return new CrustValidationError(fields(error));
|
|
22
|
+
if (error.is("PARSE")) return new CrustParseError(fields(error));
|
|
23
|
+
return new CrustCommandNotFoundError(fields(error));
|
|
24
|
+
}
|
|
25
|
+
function isCrustTaggedError(value) {
|
|
26
|
+
return value instanceof CrustDefinitionError || value instanceof CrustValidationError || value instanceof CrustParseError || value instanceof CrustCommandNotFoundError;
|
|
27
|
+
}
|
|
28
|
+
/** Matches Core's cancellation check: prompts reject with a `DOMException` named `AbortError`. */
|
|
29
|
+
function isAbortError(value) {
|
|
30
|
+
return value instanceof Error && value.name === "AbortError";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Lift a throwing thunk or promise into an Effect. A thrown `CrustError`
|
|
34
|
+
* fails with its tagged wrapper, an `AbortError` interrupts the fiber, and
|
|
35
|
+
* anything else is a defect.
|
|
36
|
+
*/
|
|
37
|
+
function tryCrust(evaluate) {
|
|
38
|
+
return Effect.tryPromise({
|
|
39
|
+
try: () => Promise.resolve().then(evaluate),
|
|
40
|
+
catch: (error) => error
|
|
41
|
+
}).pipe(Effect.catch((error) => {
|
|
42
|
+
if (isAbortError(error)) return Effect.interrupt;
|
|
43
|
+
if (error instanceof CrustError) return Effect.fail(fromCrustError(error));
|
|
44
|
+
return Effect.die(error);
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
/** Rethrow the original error behind a failed Cause so Core renders it unchanged. */
|
|
48
|
+
function unwrapCause(cause) {
|
|
49
|
+
if (Cause.hasInterruptsOnly(cause)) throw new DOMException("Effect was interrupted.", "AbortError");
|
|
50
|
+
const error = Cause.squash(cause);
|
|
51
|
+
throw isCrustTaggedError(error) ? error.cause : error;
|
|
52
|
+
}
|
|
53
|
+
/** Return the success value; failures rethrow the original error, interruption an `AbortError`. */
|
|
54
|
+
function unwrapExit(exit) {
|
|
55
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
56
|
+
return unwrapCause(exit.cause);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/layer.ts
|
|
60
|
+
/** Factories created by {@link layer}, matched by identity so a same-named plain Context never counts. */
|
|
61
|
+
const layerFactories = /* @__PURE__ */ new WeakSet();
|
|
62
|
+
/**
|
|
63
|
+
* Handler outcome per built Context, keyed by identity so nothing leaks across
|
|
64
|
+
* invocations or applications. `Scope.close` hands it to finalizers, which may
|
|
65
|
+
* branch on success versus failure/interruption (commit versus rollback).
|
|
66
|
+
*/
|
|
67
|
+
const actionExits = /* @__PURE__ */ new WeakMap();
|
|
68
|
+
/**
|
|
69
|
+
* Turn one fully composed Layer into a Crust Context whose value is the built
|
|
70
|
+
* `Context.Context`. Every `layer()` on the command path is built when a
|
|
71
|
+
* `handler()` action starts and released, in reverse order, by Crust's
|
|
72
|
+
* invocation cleanup.
|
|
73
|
+
*/
|
|
74
|
+
function layer(name, live) {
|
|
75
|
+
const factory = defineContext(name, async ({ defer }) => {
|
|
76
|
+
const scope = Scope.makeUnsafe();
|
|
77
|
+
let built;
|
|
78
|
+
defer(() => Effect.runPromise(Scope.close(scope, (built && actionExits.get(built)) ?? Exit.void)));
|
|
79
|
+
built = unwrapExit(await Effect.runPromiseExit(Layer.buildWithScope(live, scope)));
|
|
80
|
+
return built;
|
|
81
|
+
});
|
|
82
|
+
layerFactories.add(factory);
|
|
83
|
+
return factory;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/handler.ts
|
|
87
|
+
const HandlerInputBase = Context.Service()("@crustjs/effect/HandlerInput");
|
|
88
|
+
/** The invocation input, provided to every {@link handler} program so {@link service} can pull plain Contexts. */
|
|
89
|
+
var HandlerInput = class extends HandlerInputBase {};
|
|
90
|
+
function handler(fn) {
|
|
91
|
+
return async (input) => {
|
|
92
|
+
const layers = (input.ctx[contextSources] ?? []).filter((source) => "factory" in source && layerFactories.has(source.factory));
|
|
93
|
+
const bag = input.ctx;
|
|
94
|
+
const built = [];
|
|
95
|
+
const program = Effect.gen(function* () {
|
|
96
|
+
const settled = yield* Effect.promise(() => Promise.allSettled(layers.map(({ name }) => bag[name])));
|
|
97
|
+
for (const result of settled) if (result.status === "fulfilled") built.push(result.value);
|
|
98
|
+
const rejected = settled.find((result) => result.status === "rejected");
|
|
99
|
+
if (rejected) return yield* tryCrust(() => Promise.reject(rejected.reason));
|
|
100
|
+
const returned = fn(input);
|
|
101
|
+
return yield* Effect.provideContext(Effect.isEffect(returned) ? returned : Effect.gen(() => returned), Context.mergeAll(...built, Context.make(HandlerInput, input)));
|
|
102
|
+
});
|
|
103
|
+
const exit = await Effect.runPromiseExit(program);
|
|
104
|
+
for (const services of built) actionExits.set(services, exit);
|
|
105
|
+
return unwrapExit(exit);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Pull a plain Crust Context by factory from inside a {@link handler} program.
|
|
110
|
+
* Lazy like `ctx.<name>`; a name absent from the command path fails with
|
|
111
|
+
* Core's missing-context error as a `CrustDefinitionError`.
|
|
112
|
+
*/
|
|
113
|
+
function service(factory) {
|
|
114
|
+
const name = factory.contextName;
|
|
115
|
+
return Effect.flatMap(HandlerInput, (input) => tryCrust(() => {
|
|
116
|
+
const bag = input.ctx;
|
|
117
|
+
if (Object.hasOwn(bag, name)) return bag[name];
|
|
118
|
+
throw new CrustError("DEFINITION", `No provider for Context "${name}". Add .provide(${name}(...)) to the app or an ancestor command.`, {
|
|
119
|
+
subject: "context",
|
|
120
|
+
name,
|
|
121
|
+
reason: "missing-context"
|
|
122
|
+
});
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { CrustCommandNotFoundError, CrustDefinitionError, CrustParseError, CrustValidationError, fromCrustError, handler, layer, service, tryCrust };
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crustjs/effect",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Effect.ts v4 adaptor for the Crust CLI framework: tagged errors, Effect actions, and Layer-backed Contexts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "chenxin-yan",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/chenxin-yan/crust.git",
|
|
12
|
+
"directory": "packages/effect"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://crustjs.com",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/chenxin-yan/crust/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cli",
|
|
20
|
+
"effect",
|
|
21
|
+
"effect-ts",
|
|
22
|
+
"layer",
|
|
23
|
+
"crust",
|
|
24
|
+
"bun",
|
|
25
|
+
"typescript"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"tag": "next"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsdown",
|
|
42
|
+
"dev": "tsdown --watch",
|
|
43
|
+
"check:types": "tsc --noEmit",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"prepack": "cp ../../LICENSE LICENSE",
|
|
46
|
+
"postpack": "rm -f LICENSE"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@crustjs/config": "0.0.0",
|
|
50
|
+
"@crustjs/core": "0.2.1",
|
|
51
|
+
"@crustjs/testing": "0.1.1",
|
|
52
|
+
"effect": "4.0.0-rc.115",
|
|
53
|
+
"tsdown": "^0.23.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@crustjs/core": "^0.2.1",
|
|
57
|
+
"effect": "^4.0.0-rc.115",
|
|
58
|
+
"typescript": "^7.0.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"typescript": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"bun": ">=1.4.0",
|
|
67
|
+
"node": ">=22",
|
|
68
|
+
"deno": ">=2.8"
|
|
69
|
+
}
|
|
70
|
+
}
|