거의 이미 있었던 패키지 시스템
자기 자신을 컴파일하고, 브라우저에서 도는 언어가, 그럼에도 공유될 수 없었다 —— 남의 코드에 의존할 수단이 없었다. 수정은 실의 consumer로부터, 그 pain을 역산해 왔다 —— 그리고 놀라움은, 새로운 부분이 얼마나 적었는가다. resolver는 이미 cross-package import를 올바르게 해결하고 있었다; 부족했던 것은 언어도 semantics도 아니라, tooling이었다. manifest, lockfile, 그리고 runtime host를 vendor하는 수단이, released 바이너리를 「컴파일러 checkout 없이 app을 build도 run도 할 수 있는 것」으로 바꿨다.
여덟 번째 Part가 끝날 무렵, Mere는 놀랄 만큼 많은 것을 할 수 있었다: 자기 자신을 컴파일하고, 브라우저에서 돌고, HTTP를 serve하고, Postgres와 이야기하고, 네 backend를 엄밀한 일치에 유지한다. 그러면서도, 언어에 기대되는 가장 흔한 일을 할 수 없었다 —— 한 프로그램이, 남이 쓴 다른 프로그램에 의존하는 것. “이 라이브러리가 필요하다”고 말하면 그것이 나타나는, 그런 수단이 없었다. 이 gap은 컴파일러를 세우는 동안에는 보이지 않는다. 컴파일러는 통째로 자신이 소유하는 하나의 트리이기 때문이다. gap이 보이는 것은, 첫 번째 것 위에 두 번째 것을 세우려는 순간이다.
형태는 설계가 아니라, consumer로부터 왔다
패키지 시스템의 유혹은, 그것을 추상으로 설계하는 것이다 —— 레지스트리, 버전 범위, 해결 알고리즘 —— 그리고 누군가 쓰기 전에 큰 것에 다다른다. Mere는 반대의 길을 택했다. 실의 애플리케이션, Mere로 쓰인 작은 공동 편집 노트 서버를, 컴파일러 자신의 라이브러리에 의존시키고, 거기서 생긴 pain을 읽어 요건으로 삼았다.
pain은 구체적이었다. install 단계가 없어, 의존은 monorepo에서 손으로 복사되었다. 버전을 pin할
수단이 없어, 실제로 무엇에 의존하는지는 VENDOR.md에 산문으로 쓰이고, 즉시 어긋났다. 그리고
이름이 충돌했다: 라이브러리는 상대 경로로 서로를 import하고, vendor가 낳는 on-disk 디렉터리 이름은
그 import들이 기대하는 이름과 일치하지 않아, cross-package import가 깨졌다. 세 문제, 저마다 한
문장, 저마다 실재하는 프로그램으로부터. 그 목록이 명세 그 자체였다.
resolver는 이미 충분했다
시사적인 부분은 다음에 왔다. 요건을 늘어놓은 뒤의 자연스러운 가정은, 그것들을 만족시킨다는 것은 언어를 바꾸는 것이다, 라는 것이었다 —— 새로운 import 구문, 해결 pass, 버전을 위한 컴파일러 지원. 그렇지 않았다. 이미 있는 resolver가 모든 것을 올바르게 해결하고 있었다.
그 resolver는, 패키지 시스템의 첫 번째 버전부터, import하는 파일에서 파일 시스템의 root를 향해
걸어 올라가 .mere_modules/ 디렉터리를 찾는다 —— Node가 node_modules를 찾아 걸어 올라가는 것과
꼭 같은 방식이다. 하나 찾으면, "pkg/module.mere"의 import는 .mere_modules/pkg/module.mere를
읽고, vendor된 모듈 안쪽에서의 중첩 import는 그 위치에서 걸어 올라가 같은 최상위 디렉터리를
찾는다. cross-package import는, 모든 것이 하나의 프로젝트 root 아래 있는 한, 이미 Just Worked였다.
dogfood가 그것을 우연히 증명하고 있었다. 그래서 새 작업의 전체는 semantics가 아니라 tooling이었다:
.mere_modules/를 올바르게 populate하고, 그다음 길을 비켜 주는 무언가. 컴파일러는 파일이 어떻게
거기 왔는지 신경 쓰지 않는다.
manifest와 lockfile, 그 외에는 아무것도
그 tooling이 필요로 한 것은 작았다. manifest —— mere.toml —— 가 패키지와 그 의존을 선언한다.
각 의존은 정확한 커밋에서의 git 리포지터리, 임의로 그 리포지터리의 서브디렉터리, 그래서 하나의
monorepo가 많은 패키지를 host할 수 있다. mere install은 manifest를 읽고, 각 의존을 pin된 커밋으로
fetch하고, 패키지 간의 상대 import를 따라 추이적인 것도 끌어들이고, 그것들을 .mere_modules/에
쓴다. 그런 뒤 무엇을 했는지 mere.lock에 기록한다 —— 각 패키지의 해결된 풀 커밋 해시, 더해 세트
전체의 콘텐츠 해시 —— 그래서 두 번째 install은 첫 번째를 정확히 재현한다.
의도적인 생략은, 있는 것과 똑같이 중요하다. 의존은 정확한 커밋을 pin하고, 버전 범위가 아니다, 그래서 해결할 것이 없다 —— 충족 가능성 탐색도, 풀어야 할 다이아몬드도 없다. 두 패키지가 공유 의존의 다른 리비전을 원하면, 걸어 올라가기가 정하고, 수정은 solver가 아니라 deployment 층에서 처리한다. 중앙 레지스트리는 없다. 이것들은 스케줄에 맞춰 채워지기를 기다리는 gap이 아니다; 프로그램이 필요로 할 때까지 세우지 않은 채 남겨진 것이다, 언어의 나머지가 세워진 것과 같은 규율로.
이름의 매듭
원래 세 pain 중 하나는, 그 자신의 각주에 값한다. 전체가 기능하는지를 조용히 정하는 종류의 세부이기
때문이다. 레지스트리는 자연히 네임스페이스가 붙은 이름을 원한다 —— mere-http, mere-db ——
다른 저자로부터의 패키지가 충돌하지 않도록. 하지만 라이브러리 자신은 벌거벗은 상대 경로로 서로를
import한다, 하나의 트리에 나란히 살고 있을 때 쓰인 것이다: ../mere-log/log.mere가 아니라
../log/log.mere. 만약 installer가 mere-log라는 디렉터리에 패키지를 vendor하면, log를 기대하는
모든 상대 import가 깨진다.
해결은, 두 명명의 세계를 나누는 것이었다. 디스크 위의 이름, 그리고 import 안의 이름은, 벌거벗은
것이다 —— http, db, log —— 그래서 .mere_modules/는 소스 트리를 미러하고, 모든 상대 import가
계속 해결된다. mere- prefix는 리포지터리 좌표에만 산다: 그것은 패키지가 어디서 fetch되는가,
미래의 standalone repo 이름이나 monorepo 서브디렉터리이고, 컴파일러가 보는 경로에는 결코 나타나지
않는다. manifest는 둘이 묶이는 유일한 곳이고, 그런 대응이 바로 속해야 할 곳이다.
소스뿐 아니라, runtime을 전한다
첫 번째 것에 인접해, 소스 코드를 위한 패키지 시스템이 명백히는 커버하지 않는 두 번째 gap이 있었다.
컴파일된 Mere 프로그램은 .wasm 파일이고, .wasm 파일은 그 자신으로는 아무것도 하지 않는다:
puts, read_file, http_serve 같은 함수를 host로부터 import하고, 그 host 함수들은 컴파일러
자신의 리포지터리에 사는 작은 JavaScript 조각이다. 그래서 모든 소스 의존을 vendor해도, 컴파일된
app은 컴파일러의 checkout 없이는 아직 돌 수 없었다 —— 그것을 컴파일하는 의미의 많은 부분을 해친다.
수정은 manifest를 [host] 섹션으로 확장해, 그 host 조각들도 fetch하고, 내부 참조를 평탄화해
bundle이 .mere_host/ 디렉터리에서 독립해 서도록 하고, 그것에 대해 컴파일된 서버를 돌리는
mere serve를 더한다. 효과 있는 것은 이 귀결이다: released mere 바이너리, 더해 mere install이,
이제 애플리케이션을 build하는 것도 run하는 것도 충분히 할 수 있다, runtime에 컴파일러의 소스
트리가 전혀 없는 상태로. 배포는, 결국, 소스만의 이야기가 결코 아니었다; runtime host가 그것과 함께
여행할 필요가 있었고, 그렇게 된 순간, loop가 닫혔다.
작음이 산 것
패키지 시스템 전체는 의도적으로 작다 —— 레지스트리 없음, 버전 solver 없음, 정확한 커밋을 fetch하는 install과, 이미 쓰여 있던 resolver. 그것을 미완성으로 읽기는 쉬울 것이다. 연재를 통해 실천해 온 것과 같은 절제로 읽는 편이 낫다: 실의 현재 pain을 없애는 최소의 것을 세우고, 그 형태를, 패키지 시스템이 포함해야 한다는 관념이 아니라, 실제로 그것을 필요로 하는 프로그램에 정하게 한다. consumer가 명세를 썼다. 언어는, 거의, 이미 그것을 만족시키고 있었다. 남은 것은 그것을 알아차리는 것, 그리고 나머지만을 세우는 것이었다.