Skip to content

Commit b0d18dc

Browse files
authored
feat(wiring): library composition — LibraryGroup + closure-as-membership with cross-package type travel (#333)
1 parent 69ca0f6 commit b0d18dc

29 files changed

Lines changed: 1995 additions & 61 deletions

.github/workflows/check.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
- run: yarn
3333
- run: yarn build
3434
- run: yarn lint
35+
- run: yarn type-check
3536
- run: yarn test --coverage
3637
- name: Upload coverage reports to Codecov
3738
uses: codecov/codecov-action@v7.0.0
@@ -46,3 +47,6 @@ jobs:
4647

4748
test-pipelines-bun:
4849
uses: ./.github/workflows/test-pipelines-bun.yml
50+
51+
test-implies-propagation:
52+
uses: ./.github/workflows/test-implies-propagation.yml
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Test Implies Propagation
2+
3+
on:
4+
workflow_call:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
test-implies-propagation:
11+
runs-on: ubuntu-latest
12+
# cold-cache guard: run.sh builds core + two demo packages from scratch
13+
timeout-minutes: 15
14+
steps:
15+
- uses: actions/checkout@v6
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: '22'
19+
- run: corepack enable
20+
- run: yarn config set enableImmutableInstalls false
21+
- run: yarn
22+
# Builds local core, type-checks the consumer (the proof that an implied
23+
# library's types travel across the package boundary), then runs the demo.
24+
# If a future TS/core change breaks the function-declaration import edge,
25+
# params.lighting stops resolving and this step goes red.
26+
- name: Demo + regression guard (implies cross-package type propagation)
27+
run: bash examples/implies-propagation/run.sh

.github/workflows/test-pipelines-deno.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
- name: Test config-error with deno
1919
run: |
2020
set +e
21-
output=$(deno run --allow-read --allow-env testing/pipelines/config-error.mts 2>&1)
21+
output=$(deno run --allow-read --allow-env --allow-sys testing/pipelines/config-error.mts 2>&1)
2222
exit_code=$?
2323
echo "Exit code: $exit_code"
2424
echo "Output: $output"
@@ -34,7 +34,7 @@ jobs:
3434
- name: Test thrown-bootstrap-error with deno
3535
run: |
3636
set +e
37-
output=$(deno run --allow-read --allow-env testing/pipelines/thrown-bootstrap-error.mts 2>&1)
37+
output=$(deno run --allow-read --allow-env --allow-sys testing/pipelines/thrown-bootstrap-error.mts 2>&1)
3838
exit_code=$?
3939
echo "Exit code: $exit_code"
4040
echo "Output: $output"
@@ -50,7 +50,7 @@ jobs:
5050
- name: Test simple-success with deno
5151
run: |
5252
set +e
53-
output=$(deno run --allow-read --allow-env testing/pipelines/simple-success.mts 2>&1)
53+
output=$(deno run --allow-read --allow-env --allow-sys testing/pipelines/simple-success.mts 2>&1)
5454
exit_code=$?
5555
echo "Exit code: $exit_code"
5656
echo "Output: $output"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,4 @@ dynamic.d.mts
125125
/obsidian/.obsidian
126126
.yarn/install-state.gz
127127
.env
128+
examples/**/dist

cspell.config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ words:
142142
- reftimes
143143
- RGBW
144144
- RGBWW
145+
- rollup
146+
- rollups
145147
- rosybrown
146148
- royalblue
147149
- rrule
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# `implies` type propagation — demo + regression guard
2+
3+
A tiny two-package workspace that proves a non-obvious property of
4+
`@digital-alchemy/core`: when a library lists another library in `implies`, a
5+
downstream consumer that imports **only** the first library gets the second one
6+
both **wired at runtime** and **fully typed at compile time** — with no
7+
`LoadedRollups` registration and no manual re-export.
8+
9+
It is also a durable regression guard: it compiles and runs against the real
10+
local core, so if a future TypeScript release changes how declaration-merge
11+
augmentations travel across package boundaries, `run.sh` fails loudly.
12+
13+
## Run it
14+
15+
```bash
16+
bash run.sh
17+
```
18+
19+
That builds core, builds `@demo/home-libraries`, type-checks `@demo/home-app`
20+
(the proof), then runs it (the demo). Expected output ends with the lights being
21+
driven by a library the app never imported:
22+
23+
```
24+
[INFO][living_room:Scenes]: ☀️ good morning
25+
[INFO][lighting:Lights]: 💡 lights → 80%
26+
[INFO][home:Routine]: lighting reports 80% — typed, no registration
27+
```
28+
29+
## Layout
30+
31+
| Package | Role |
32+
|---|---|
33+
| `home-libraries/src/lighting.mts` | A plain library. Its service is a **named `function` declaration** — the load-bearing detail. |
34+
| `home-libraries/src/living-room.mts` | `implies: [LIGHTING]` (membership + types) and `depends: [LIGHTING]` (wiring order). Registers only `living_room`. |
35+
| `home-app/src/main.mts` | Imports **only** `LIVING_ROOM`, lists **only** `LIVING_ROOM`. Uses `params.lighting` — typed and live. |
36+
| `home-app/src/proof.mts` | Type-only guard (`@ts-expect-error` probes) — never executed, only type-checked. |
37+
38+
## Why it works (and why named functions matter)
39+
40+
Capturing `implies` as a literal tuple makes the implier's emitted `.d.mts`
41+
reference each member. With **named-function** services the reference is a real
42+
module edge:
43+
44+
```ts
45+
// living-room.d.mts — NAMED fn: a genuine import edge to lighting.mjs
46+
readonly [LibraryDefinition<{ Lights: typeof import("./lighting.mjs").Lights }, …>]
47+
```
48+
49+
That `import("./lighting.mjs")` pulls `lighting.d.mts` into the consumer's
50+
program, so its `declare module { interface LoadedModules { lighting } }`
51+
augmentation activates and `TServiceParams` gains `lighting` automatically.
52+
53+
An **arrow** service would be inlined anonymously with **no** edge:
54+
55+
```ts
56+
// hypothetical arrow version — anonymous, no edge, augmentation never travels
57+
readonly [LibraryDefinition<{ read: (p: TServiceParams) => { … } }, …>]
58+
```
59+
60+
so the member would wire at runtime but `params.<member>` would be untyped. Named
61+
function declarations are not a style preference here — they are what makes the
62+
cross-package type edge exist.
63+
64+
## Membership vs. ordering
65+
66+
The two fields on `living_room` are orthogonal and both intentional:
67+
68+
- `implies: [LIGHTING]`**membership + types.** The app can omit `lighting`
69+
from `libraries`; it is pulled into the resolved set and its types travel.
70+
- `depends: [LIGHTING]`**ordering only.** `params` is a wire-time snapshot, so
71+
`lighting` must wire *before* `living_room` for `Scenes` to call it.
72+
73+
Drop `implies` and the app must list `lighting` itself (or boot throws
74+
`MISSING_DEPENDENCY`). Drop `depends` and `lighting` may wire after `living_room`,
75+
leaving `params.lighting` undefined at call time.
76+
77+
## The core change this depends on
78+
79+
One thing: `CreateLibrary` captures `implies` as a `const` tuple, carried as a
80+
third type parameter on `LibraryDefinition`. No `TServiceParams` machinery —
81+
deriving member keys for `TServiceParams` directly is provably circular; letting
82+
the member's own augmentation ride the import edge is what avoids that.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "name": "@demo/home-app", "version": "1.0.0", "type": "module", "private": true }
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Downstream app. The whole point of this file:
3+
*
4+
* - It imports ONLY `LIVING_ROOM`.
5+
* - It lists ONLY `LIVING_ROOM` in `libraries`.
6+
* - It never imports, names, or registers `lighting` anywhere.
7+
*
8+
* Yet `params.lighting` is fully typed AND wired at runtime — it arrived purely
9+
* through `LIVING_ROOM`'s `implies`. Membership comes along (runtime), and the
10+
* types come along (compile time), with zero extra wiring.
11+
*/
12+
import { CreateApplication, type TServiceParams } from "@digital-alchemy/core";
13+
import { LIVING_ROOM } from "@demo/home-libraries/living-room";
14+
15+
function Routine({ living_room, lighting, lifecycle, logger }: TServiceParams) {
16+
lifecycle.onReady(() => {
17+
logger.info("— a day in the (imaginary) living room —");
18+
living_room.Scenes.goodMorning();
19+
20+
// `lighting` was never imported or listed here. It is implied by LIVING_ROOM.
21+
// It is typed (try `lighting.Lights.dim("bright")` — it won't compile) and live.
22+
logger.info(`lighting reports ${lighting.Lights.brightness}% — typed, no registration`);
23+
24+
living_room.Scenes.goodNight();
25+
logger.info(`...and down to ${lighting.Lights.brightness}%`);
26+
});
27+
}
28+
29+
const HOME = CreateApplication({
30+
name: "home",
31+
libraries: [LIVING_ROOM], // only the top library; `lighting` is implied
32+
services: { Routine },
33+
});
34+
35+
declare module "@digital-alchemy/core" {
36+
interface LoadedModules {
37+
home: typeof HOME;
38+
}
39+
}
40+
41+
await HOME.bootstrap();
42+
await HOME.teardown();
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Type-only regression guard. NOT executed — only type-checked by `tsc`.
3+
*
4+
* The acceptance criterion this guards: a file that references NOTHING — it
5+
* imports neither the implier (LIVING_ROOM) nor the implied member (lighting) —
6+
* still sees `lighting` fully typed on `TServiceParams`. The app root
7+
* (main.mts) lists only LIVING_ROOM; the `implies`/`depends` augmentation is
8+
* program-global, so every file in the app gets the implied member's types with
9+
* zero registration and zero references. No import here is deliberate — adding
10+
* one to "make the types appear" would be proving a weaker thing than the AC.
11+
*
12+
* If the propagation regresses, this fails two ways:
13+
* 1. `lighting` disappears from `TServiceParams` -> the destructure below errors.
14+
* 2. `lighting` degrades to `any` -> the `@ts-expect-error` directives below
15+
* become UNUSED, which is itself a compile error.
16+
*/
17+
import type { TServiceParams } from "@digital-alchemy/core";
18+
19+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
20+
function _guard({ lighting }: TServiceParams) {
21+
// present AND correctly typed (brightness is a number)
22+
const level: number = lighting.Lights.brightness;
23+
void level;
24+
25+
// @ts-expect-error brightness is a number, not a string — only catchable if genuinely typed
26+
const wrong: string = lighting.Lights.brightness;
27+
void wrong;
28+
29+
// @ts-expect-error dim() takes a number, not a string — only catchable if genuinely typed
30+
lighting.Lights.dim("bright");
31+
32+
// @ts-expect-error there is no `flicker` method — only catchable if genuinely typed
33+
lighting.Lights.flicker();
34+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"module": "nodenext",
4+
"moduleResolution": "nodenext",
5+
"target": "es2022",
6+
"noEmit": true,
7+
"strict": true,
8+
"skipLibCheck": true
9+
},
10+
"include": ["src"]
11+
}

0 commit comments

Comments
 (0)