Skip to content

Development target of the api's #runtime/v1/* import is not a valid package target #1653

Open

Description

@joshunrau

The development target of the #runtime/v1/* subpath import in apps/api/package.json is ../../runtime/v1/dist/*. Node's resolver accepts an imports target only if it is package-relative (./…) or a bare specifier, so Node rejects this one: under --conditions=development, resolving #runtime/v1/... throws ERR_INVALID_PACKAGE_TARGET. Production uses the default target (./dist/runtime/v1/*), which is valid and unaffected. In pnpm dev the main thread does not hit the error either, because libnest dev registers @swc-node/register, whose oxc-resolver accepts the ../ target. Two places do hit it:

  • Worker threads under pnpm dev. Module hooks registered in the main thread are not inherited, so export-worker.js throws when importing #runtime/v1/.... It works only because of a try/catch that falls back to @opendatacapture/runtime-v1/....
  • Vitest. InstrumentsModule's __resolveImport cannot be exercised under the development condition.

The effect is limited to tooling and dev, but the target is invalid by spec, works only because of one resolver's leniency, and has already led to a workaround in export-worker.js.

Where

  • apps/api/package.json:72-76:

    "imports": {
      "#runtime/v1/*": {
        "development": "../../runtime/v1/dist/*",
        "default": "./dist/runtime/v1/*"
      }
    }
  • apps/api/src/instruments/instruments.module.ts:15-20: import.meta.resolve(specifier.replace(/^\/runtime/, '#runtime')).

  • apps/api/src/instrument-records/export-worker.js:9-16: the fallback added for this ("we need to resolve import correctly in development").

The target has had this shape since 20de0ee1c (libnest rewrite).

Reproduce

From apps/api on Node 24.15:

node --conditions=development --input-type=module -e 'console.log(import.meta.resolve("#runtime/v1/@opendatacapture/runtime-internal/index.js"))'

Actual: ERR_INVALID_PACKAGE_TARGET: Invalid "imports" target "../../runtime/v1/dist/*" defined for '#runtime/v1/*'.
Expected: file:///…/runtime/v1/dist/@opendatacapture/runtime-internal/index.js.

The same call succeeds when the swc-node hook is registered in the same thread, and fails in a Worker spawned from that thread.

Tests

apps/api/src/instruments/__tests__/instruments.module.spec.ts, test "should resolve a runtime import to the built runtime, even under the development condition", is marked it.fails. Change it to it once the target is fixed. Its comment says pnpm dev is affected; correct that, since the main thread under pnpm dev resolves the target through swc-node.

Suggested fix

Change the development target to the bare specifier @opendatacapture/runtime-v1/*. The api already depends on that package, and its exports maps ./* to ./dist/*. A scratch package using this target resolved to the same runtime/v1/dist/... URL under plain Node with --conditions=development. The try/catch fallback in export-worker.js can then be removed.

Found while raising unit test coverage in #1632.

Activity

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    on Oct 5, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Two corrections, though. The main thread passes for a different reason than the issue gives. And the suggested fix alone does not let the export-worker.js fallback go: without the fallback, the datahub export e2e tests fail in CI.

    Corrections

    • oxc-resolver is not lenient. oxc-resolver 11.20.0 also rejects the target when development is set (Invalid "exports" target "../../runtime/v1/dist/*" defined for '#runtime/v1/*'). The main thread gets through because @swc-node/register 1.12.1 gives oxc-resolver the cwd tsconfig.json, and the paths entry at apps/api/tsconfig.json:10 matches before imports is read. Under dev and dev:test alike, the main thread never reads package.json imports.
    • The fallback also runs in CI, for a different error. Playwright starts the api with pnpm dev:test (testing/playwright.config.ts:45), which sets no development condition (apps/api/package.json:11). Its workers therefore take default, ./dist/runtime/v1/*. CI never builds the api: .github/workflows/ci.yaml has no build step before pnpm test:e2e, and no workspace depends on @opendatacapture/api, so no ^build reaches it. The worker's import throws ERR_MODULE_NOT_FOUND, and the fallback is what lets the exports in testing/src/specs/datahub.spec.ts:25 and :174 pass. Locally, a leftover apps/api/dist makes the worker load that stale runtime copy without any error.
    • The Docker image never reads apps/api/package.json. apps/api/Dockerfile:29 writes its own ./dist/runtime/v1/* mapping.

    In a scratch package with no dist, the suggested { "development": "@opendatacapture/runtime-v1/*", "default": "./dist/runtime/v1/*" } resolves from a Worker under --conditions=development and fails with ERR_MODULE_NOT_FOUND without it.

    Plan

    Use one bare target with no condition:

    "imports": {
      "#runtime/v1/*": "@opendatacapture/runtime-v1/*"
    }

    In the same scratch setup this resolves to runtime/v1/dist/... from a Worker with and without the condition. Docker brings its own mapping, so default only ever served dev:test and pnpm start from a checkout, and both have node_modules. The alternative is to keep the conditional object and add NODE_OPTIONS='--conditions=development' to dev:test, which leaves two scripts that must carry the same flag.

    1. apps/api/package.json:71-76: the target above.
    2. apps/api/src/instrument-records/export-worker.js:6-16: replace the let and the try/catch with a static import { removeSubjectIdScope } from '#runtime/v1/@opendatacapture/runtime-internal/index.js'.
    3. apps/api/src/instrument-records/__tests__/export-worker.test.ts: delete the fallback test (:80-97), RUNTIME_INTERNAL_SPECIFIER (:15) and the vi.doUnmock in beforeEach (:73). Nothing else uses them.
    4. apps/api/src/instruments/__tests__/instruments.module.test.ts:29-33: drop the known-defect comment and change it.fails to it. Vitest 4.1 starts its workers with --conditions development, and its module runner hands import.meta.resolve to Node, so this test checks the real target. The comment correction this issue asks for already landed in 52ae6f2, and the file has been .test.ts since efa9070.
    5. Docs: rewrite the try/catch rationale at .agents/docs/architecture/runtime-and-vendor.md:127-129 and the development target at .agents/docs/workspace-map.md:80-81. apps/api/AGENTS.md:274-276, .agents/docs/architecture/instrument-pipeline.md:124-125 and runtime-and-vendor.md:124 and :193 say the mapping lives in two files. Add apps/api/Dockerfile:29 as a third, paired with the dist/runtime/v1 copy at apps/api/libnest.config.ts:34, because the Docker mapping now deliberately differs from package.json.

    Tests

    Steps 3 and 4 cover the unit side. No new e2e test is needed, because the two export tests in datahub.spec.ts already run the worker under dev:test. Run them with no apps/api/dist, as in CI. A leftover dist hides a mapping that still points at ./dist.

    #1807 also edits both export-worker files, but not the import block. Expect at most a small conflict near export-worker.test.ts:15.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions