npm.io
2.0.1 • Published 17h ago

workspaces-effect

Licence
MIT
Version
2.0.1
Deps
8
Size
350 kB
Vulns
0
Weekly
0

workspaces-effect

npm License: MIT

An Effect library for monorepo tooling. It discovers workspaces, builds dependency graphs, detects changes from git, parses lockfiles and reports which packages are publishable. Supports npm, pnpm, yarn Berry and Bun workspaces, runs on Node.js or Bun.

Features

  • Workspace discovery for npm, pnpm, yarn Berry and Bun; package-manager detection is automatic
  • Package metadata with computed getters, dependency queries and a dual API (instance methods, static data-first functions, pipeable variants)
  • Dependency graph with topological sort, parallel build levels and cycle detection
  • Git-driven change detection that returns the affected packages for a diff
  • Lockfile parsing for all four package managers, including integrity checks against package.json ranges
  • Catalog resolution that rewrites catalog: and workspace: specifiers to concrete versions; pnpm catalogs are assembled from inline, config-dependency and lockfile sources (workspace: still resolves on npm, yarn and Bun)
  • Point-in-time workspace reading — packages and catalogs as they existed at any git ref, or the live working tree, without checking anything out
  • Runs on Node.js or Bun via @effect/platform adapters — no node: imports leak into your code
  • Synchronous helpers (findWorkspaceRootSync, getWorkspacePackagesSync) for places Effect cannot reach, like lint-staged hooks

Install

effect and @effect/platform are peer dependencies. Install them alongside the platform adapter for your runtime.

# For Node.js
npm install workspaces-effect effect @effect/platform @effect/platform-node
# or
pnpm add workspaces-effect effect @effect/platform @effect/platform-node

# For Bun
bun add workspaces-effect effect @effect/platform @effect/platform-bun

Quick start

import { Effect, Option, pipe } from "effect";
import { NodeContext } from "@effect/platform-node";
import {
  WorkspaceDiscovery,
  WorkspacePackage,
  WorkspacesLive,
} from "workspaces-effect";

const program = Effect.gen(function* () {
  const discovery = yield* WorkspaceDiscovery;
  const packages = yield* discovery.listPackages();

  for (const pkg of packages) {
    // Computed getters
    if (pkg.isRootWorkspace) continue; // skip the root package
    console.log(pkg.unscopedName, pkg.isPublic ? "(public)" : "(private)");
    // example output (varies): "utils (public)"

    // Instance method
    if (pkg.hasAnyDependencyOn("effect")) {
      const version = pkg.dependencyVersion("effect");
      console.log("  effect:", Option.getOrElse(version, () => "n/a"));
      // example output (varies): "  effect: <version>"
    }
  }

  // Static data-last (pipeable) style
  const usesReact = packages.filter(
    pipe(WorkspacePackage.hasAnyDependencyOn("react")),
  );
});

Effect.runPromise(
  program.pipe(
    Effect.provide(WorkspacesLive),
    Effect.provide(NodeContext.layer),
  ),
);

Two composite layers handle the common wiring:

  • WorkspacesLive — every git-free service (requires FileSystem + Path)
  • WorkspacesFullLive — adds the git-backed services: change detection and point-in-time workspace reading (also requires CommandExecutor)

Custom publishability detectors

PublishabilityDetector is a Context.Tag like every other service. When the default publish semantics don't fit — say your org mirrors every public package to an internal registry — swap it out with Layer.succeed. Provide your custom layer instead of PublishabilityDetectorLive and every consumer that yields PublishabilityDetector gets the new behavior.

import { Effect, Layer } from "effect";
import { NodeContext } from "@effect/platform-node";
import {
  PublishabilityDetector,
  PublishTarget,
  WorkspaceDiscovery,
  WorkspacesLive,
} from "workspaces-effect";

// Always publish to GitHub Packages alongside the package's own publishConfig
// target — common for orgs that mirror to a private registry.
const ORG_REGISTRY = "https://npm.pkg.github.com/";

const OrgMirrorDetector = Layer.succeed(PublishabilityDetector, {
  detect: (pkg, _root) =>
    Effect.sync(() => {
      if (pkg.private && !pkg.publishConfig?.access) return [];

      const primary = new PublishTarget({
        name: pkg.name,
        registry: pkg.publishConfig?.registry ?? "https://registry.npmjs.org/",
        directory: pkg.publishConfig?.directory ?? ".",
        access: pkg.publishConfig?.access ?? "public",
        provenance: false,
      });

      const mirror = new PublishTarget({
        name: pkg.name,
        registry: ORG_REGISTRY,
        directory: pkg.publishConfig?.directory ?? ".",
        access: pkg.publishConfig?.access ?? "restricted",
        provenance: false,
      });

      return primary.registry === mirror.registry ? [primary] : [primary, mirror];
    }),
});

const program = Effect.gen(function* () {
  const discovery = yield* WorkspaceDiscovery;
  const detector = yield* PublishabilityDetector;
  for (const pkg of yield* discovery.listPackages()) {
    const targets = yield* detector.detect(pkg, "/path/to/root");
    if (targets.length > 0) console.log(pkg.name, targets.map((t) => t.registry));
    // example output (varies): "@myorg/ui ['https://registry.npmjs.org/', 'https://npm.pkg.github.com/']"
  }
});

Effect.runPromise(
  program.pipe(
    Effect.provide(OrgMirrorDetector),
    Effect.provide(WorkspacesLive),
    Effect.provide(NodeContext.layer),
  ),
);

WorkspaceRoot, PackageManagerDetector, LockfileReader and every other service in the library expose Context.Tags, so the same swap works for any of them.

Observability

workspaces-effect says nothing at the default log level. Internal events — workspace root discovery, package manager detection, lockfile reads, change detection and the rest — go through Effect's structured logger at Debug level with annotations like workspace.root, workspace.pm and workspace.packages.count.

To see them, lower the minimum log level:

import { Effect, Logger, LogLevel } from "effect";

Effect.runPromise(
  program.pipe(
    Effect.provide(WorkspacesLive),
    Effect.provide(NodeContext.layer),
    Logger.withMinimumLogLevel(LogLevel.Debug),
  ),
);

To send events somewhere other than the console — a collector, OpenTelemetry, a test sink — replace or add a logger:

import { Effect, Logger } from "effect";

const collectingLogger = Logger.make(({ logLevel, message, annotations }) => {
  // ship to your destination of choice; logLevel is usually what you route on
});

Effect.runPromise(
  program.pipe(
    Effect.provide(WorkspacesLive),
    Effect.provide(NodeContext.layer),
    Effect.provide(Logger.replace(Logger.defaultLogger, collectingLogger)),
  ),
);

Errors still travel through the typed error channel (WorkspaceRootNotFoundError, LockfileReadError and the rest). The logger only carries informational events.

Lazy lockfile and discovery initialization

LockfileReaderLive and WorkspaceDiscoveryLive defer every bit of I/O — root discovery, package-manager detection, lockfile read, lockfile parse — until the first service method runs. The work is memoized for the layer's lifetime via Effect.cached, so layer construction is O(1). That matters when you build the layer per call site, like a Vitest reporter that spans multiple projects or a CLI that composes layers per subcommand.

Init-time errors surface from each method's E channel as members of an exported union:

import type { LockfileInitError } from "workspaces-effect";

// LockfileInitError =
//   | WorkspaceRootNotFoundError
//   | PackageManagerDetectionError
//   | LockfileReadError
//   | LockfileParseError

Every LockfileReader method (readLockfile, resolvedVersion, workspaceDependencies, checkIntegrity) lists LockfileInitError in its E channel. checkIntegrity adds LockfileIntegrityError. Handle them at the call site, not around Effect.provide:

import { Effect } from "effect";
import { LockfileReader, WorkspacesLive } from "workspaces-effect";

const program = Effect.gen(function* () {
  const reader = yield* LockfileReader;
  return yield* reader.readLockfile();
}).pipe(
  Effect.catchTag("LockfileReadError", (e) =>
    Effect.logWarning(`No lockfile at ${e.lockfilePath}: ${e.reason}`),
  ),
  Effect.catchTag("LockfileParseError", (e) =>
    Effect.logError(`Cannot parse ${e.format} lockfile at ${e.lockfilePath}`),
  ),
);

The Lazy initialization section of the lockfile guide goes deeper.

Point-in-time workspace reading

PointInTimeWorkspace reads workspace state as it existed at any git ref, or the live working tree, without checking anything out. at(ref) reads each package's package.json and the workspace's pnpm catalogs via git show/git ls-tree over CommandExecutor — no temporary checkout, no node:child_process. worktree() reads the same shape off disk via WorkspaceDiscovery. Both return a WorkspaceStateSnapshot: the packages plus an assembled CatalogSet, so catalog: and workspace: specifiers resolve against the state as it existed then, not against the current tree.

import { Effect, Option } from "effect";
import { NodeContext } from "@effect/platform-node";
import { PointInTimeWorkspace, WorkspacesFullLive } from "workspaces-effect";

const program = Effect.gen(function* () {
  const pointInTime = yield* PointInTimeWorkspace;

  const atMainTip = yield* pointInTime.at("main");
  const live = yield* pointInTime.worktree();

  // Compare a package's declared version across the two snapshots
  const before = atMainTip.package("my-lib"); // Option<PackageStateSnapshot>
  const after = live.package("my-lib");

  // Resolve a catalog: specifier as it existed at that ref
  const resolved = atMainTip.resolve("effect", "catalog:default");
  console.log(Option.getOrElse(resolved, () => "not in any catalog"));
  // example output (varies): the range the default catalog held at that ref
});

Effect.runPromise(
  program.pipe(
    Effect.provide(WorkspacesFullLive),
    Effect.provide(NodeContext.layer),
  ),
);

at snapshots are cached per resolved root and ref — git history is immutable, so they never go stale. The cache holds 64 entries, evicts the least recently used past that and never caches failures, so a failed read retries on the next call. worktree re-reads on every call. Both methods take an optional { cwd } and resolve the workspace root by walking up from it, the same semantics as WorkspaceDiscovery; when omitted, the walk starts at process.cwd(). A file absent at the ref is a skip, not an error: a package added since that commit simply does not appear in the older snapshot. Each method has its own error union: at fails with PointInTimeAtError (GitReadError | CatalogAssemblyError | WorkspaceRootNotFoundError), worktree with PointInTimeWorktreeError (CatalogAssemblyError | WorkspaceRootNotFoundError | WorkspaceDiscoveryError), and PointInTimeReadError unions both for handling them in one place.

Each snapshot assembles its catalogs from the lockfile first, then the inline pnpm-workspace.yaml catalogs; inline wins per dependency. Snapshots see config-dependency-injected catalogs through the lockfile's catalogs: record — pnpmfile hook replay is an overlay only the live CatalogResolver applies by default.

PointInTimeWorkspace ships in WorkspacesFullLive only. It needs CommandExecutor for the git reads, so the git-free WorkspacesLive composite does not include it — provide WorkspacesFullLive or wire PointInTimeWorkspaceLive yourself. The value objects are plain Schema.Class values exported directly: CatalogSet (fromWorkspaceYaml, fromLockfileCatalogs, merge, resolveSpecifier) and WorkspaceStateSnapshot (packages, catalogs, package, resolve), so you can build your own point-in-time comparisons without the service.

Two limitations. A configDependencies edit in pnpm-workspace.yaml that has not been pnpm installed yet is invisible to snapshots, since hook replay is that live-resolver overlay. And workspace glob expansion is one directory level deep (packages/** is treated as packages/*) — at(ref) and live discovery share one pattern core, so ! negations and everything else behave identically in both.

Documentation

  • Getting started — installation, first program, layers, platform setup and synchronous utilities
  • WorkspacePackage API — computed getters, dependency queries, dual-API pattern, diffs, PublishConfig and readPackageJson
  • Dependency analysis — dependency graphs, topological sorting, parallel build levels and cycle detection
  • Change detection — git-based change detection, affected packages and CI pipeline integration
  • Lockfile parsing — unified lockfile reading, resolved versions, workspace dependencies, integrity checking and PM extensions
  • Publishability — detecting publishable packages, publish targets and selective publishing workflows
  • Architecture overview — service groups, layer composition, platform independence and error model
  • Services reference — API reference for every service
  • Troubleshooting — every error type with causes and solutions

License

MIT

Keywords