@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 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)
@@ -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
+ }